Lesson overview · Free interview practice

Decorators

Master class and method decorators.

Topics in the full lesson
  • Introduction to Decorators
  • Decorator Factories
  • Class Decorators
  • Method Decorators
  • Property Decorators
  • Parameter Decorators
  • Metadata Reflection API
  • Decorators in Practice
  • Standard Decorators (TypeScript 5.0+, Stage 3 proposal)
  • Best Practices
  • Exercises

Practice Problems

Work through a question before revealing its explanation. These questions and answers are free; Premium adds the full lesson walkthrough, examples and implementation detail.

Completion marks record your own progress, not an automatically checked result. The task type does not determine whether it is optional.

TheoryHard

Class Decorators vs Method Decorators

Question

Explain the difference between a class decorator and a method decorator. Show examples and the canonical use cases for each.

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryHard

Decorators: TC39 Stage 3 vs Legacy Experimental

Question

TypeScript has TWO decorator systems as of 5.0: the legacy "experimental decorators" (TS-specific) and the modern Stage 3 decorators (TC39 standard). What's the difference, and which one should new projects use?

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryHard

The Metadata Reflection API: `reflect-metadata` + Modern Successor

Question

What is the Metadata Reflection API, and how does it work with decorators? What's the difference between the legacy reflect-metadata library and the upcoming standard Symbol.metadata?

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryHard

Dependency Injection with Decorators

Question

How do TypeScript frameworks like NestJS use decorators to implement dependency injection? Walk through the pattern.

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

CodingHard

Method Decorators: Wrapping Behavior

Implement and check your solution in the stated environment before revealing the answer.

Question

Implement a method decorator named timed twice: once with TypeScript's modern decorator API and once with its legacy experimental API.

Role: core-practice. This practices the method wrapping taught in this lesson. Before starting, know generic function signatures, class methods, this forwarding and try/finally. The two compiler modes are part of the exercise, not interchangeable syntax.

The behavior to preserve

  • Decorate non-generic public instance or static methods with a single call signature, including symbol-named methods. Optional and rest parameters are allowed. Call the original exactly once with the same receiver and arguments.
  • Measure synchronous execution with performance.now(). Log exactly one string per invocation in the form add took 2.50ms, using the method name and two decimal places.
  • Return the exact original result. On a synchronous throw, still log once and rethrow the same value.
  • Preserve the supported method's TypeScript receiver, argument and return types. Use generics in the decorator implementation, not any casts. Generic methods and overloaded methods are outside this exercise's shared two-mode contract.
  • Do not log while the class is being defined. Put per-call work in the replacement method.

This required version measures only the synchronous call frame. If a method returns a Promise, return that same Promise and log when the method returns it. Do not await it or claim to measure its eventual completion. Async settlement timing is a separate extension, not required here.

The decorated method itself must not introduce type parameters. For example, identity<T>(value: T): T is outside the supported shape. An out-of-scope signature might compile in one decorator mode but fail in the other. You do not need to detect or reject every unsupported signature.

Assume the clock and logger work and do not throw. Fields, accessors, private methods, parameter decorators, metadata, factories, memoization and retry are outside the implementation contract. Recursive calls each get their own timing entry.

Set up before opening the answer

Use TypeScript 5.9.3 and Node.js 24.21.0. Confirm tsc --version and node --version. In a fresh folder, create separate modern and legacy directories. Do not compile both with one configuration.

Save this package.json in the parent folder so the emitted JavaScript is read as CommonJS:

{
  "private": true,
  "type": "commonjs"
}

modern/tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "CommonJS",
    "lib": ["ES2022", "DOM", "ESNext.Decorators"],
    "types": [],
    "experimentalDecorators": false,
    "emitDecoratorMetadata": false,
    "noEmitOnError": true,
    "outDir": "./dist"
  },
  "files": ["solution.ts", "checks.ts"]
}

legacy/tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "CommonJS",
    "lib": ["ES2022", "DOM"],
    "types": [],
    "experimentalDecorators": true,
    "emitDecoratorMetadata": false,
    "noEmitOnError": true,
    "outDir": "./dist"
  },
  "files": ["solution.ts", "checks.ts"]
}

The DOM library supplies declarations for console and performance. The runtime is Node, not a browser. Neither mode needs reflect-metadata, and metadata emission is off.

modern/solution.ts:

export function timed<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
): (this: This, ...args: Args) => Return {
  return function (this: This, ...args: Args): Return {
    throw new Error('TODO: timed')
  }
}

legacy/solution.ts:

export function timed<This, Args extends unknown[], Return>(
  target: object,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Return>,
): void {
  descriptor.value = function (this: This, ...args: Args): Return {
    throw new Error('TODO: timed')
  }
}

Save the following code as both modern/checks.ts and legacy/checks.ts:

import { timed } from './solution'

function equal(actual: unknown, expected: unknown): void {
  if (!Object.is(actual, expected)) throw new Error('timing assertion failed')
}
const logs: string[] = []
const error = new Error('same failure')
const oldLog = console.log
const oldNow = Object.getOwnPropertyDescriptor(performance, 'now')
let time = 0
console.log = (value: unknown) => { logs.push(String(value)) }
Object.defineProperty(performance, 'now', {
  value: () => { time += 2.5; return time },
  configurable: true,
})
class Calculator {
  constructor(public base: number) {}
  @timed
  add(a: number, b: number): number { return this.base + a + b }
  @timed
  fail(): never { throw error }
  @timed
  total(first: number = 0, ...rest: number[]): number {
    return this.base + first + rest.reduce((sum, value) => sum + value, 0)
  }
}
function checkTypes(value: Calculator) {
  const result: number = value.add(1, 2)
  // @ts-expect-error Argument types must remain intact.
  value.add('1', 2)
  // @ts-expect-error Return types must remain intact.
  const wrong: string = value.add(1, 2)
  const total: number = value.total(1, 2, 3)
  // @ts-expect-error A supported rest parameter must retain its element type.
  value.total(1, 'two')
}
try {
  equal(logs.length, 0)
  const calculator = new Calculator(10)
  equal(calculator.add(1, 2), 13)
  equal(logs[0], 'add took 2.50ms')
  let caught: unknown
  try { calculator.fail() } catch (value) { caught = value }
  equal(caught, error)
  equal(logs[1], 'fail took 2.50ms')
  equal(calculator.total(), 10)
  equal(logs[2], 'total took 2.50ms')
  equal(calculator.total(1, 2, 3), 16)
  equal(logs[3], 'total took 2.50ms')
  equal(logs.length, 4)
} finally {
  console.log = oldLog
  if (oldNow) Object.defineProperty(performance, 'now', oldNow)
  else Reflect.deleteProperty(performance, 'now')
}
console.log('timing checks passed')

From the parent folder, run:

tsc -p modern/tsconfig.json && node modern/dist/checks.js
tsc -p legacy/tsconfig.json && node legacy/dist/checks.js

Both starters should compile but fail at runtime with TODO: timed. Both completed versions should print timing checks passed. The controlled clock makes the acceptance values repeatable without benchmarking your machine.

Stop when both modes pass and you can explain why the wrapper must be a regular function. Do not use Node's type stripping as your compiler check.

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

Explore the full Decorators material

Premium includes the complete lessons and implementation references. Free practice questions remain available without a subscription.

All course tracks & premium content
From basics to advanced masterclasses
Built for JS/TS developers like you
Real-world tips & common pitfalls
Upgrade to Premium