TypeScript · lesson 12 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Decorators in TypeScript
Standard TC39 decorators for methods, classes, accessors and fields, then legacy experimentalDecorators with metadata-driven DI — all with real output.
By SkillByExample · Updated
Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. Node can’t run decorators itself, so tsx (or tsc) has to transform them first.
What Is a Decorator?
A decorator is a function applied with @ to a class or class member. For a method, it receives the original method and a context object, and may return a replacement.
function logged<This, Args extends unknown[], Return>(
method: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
const name = String(context.name);
return function (this: This, ...args: Args): Return {
console.log(`-> ${name}(${args.join(", ")})`);
const result = method.call(this, ...args);
console.log(`<- ${name} = ${result}`);
return result;
};
}
class Calculator {
@logged
add(a: number, b: number): number {
return a + b;
}
}
console.log(new Calculator().add(2, 3) * 10);
$ npx tsc --noEmit --module nodenext logged.ts
$ npx tsx logged.ts
-> add(2, 3)
<- add = 5
50
The method body never mentions logging. The generic parameters make the replacement keep add’s exact signature, and the compiler checks that it does. These are standard (TC39) decorators — no compiler flag is needed. Node itself can’t parse them:
$ node logged.ts 2>&1 | grep -m1 Error
SyntaxError: Invalid or unexpected token
Decorators with Arguments, and Their Order
A decorator factory is a function that returns a decorator, so it can take options. When several decorators stack, they’re applied bottom-up — the one nearest the method wraps it first.
type Method<T, A extends unknown[], R> = (this: T, ...args: A) => Promise<R>;
function retry(times: number) {
return function <T, A extends unknown[], R>(method: Method<T, A, R>, ctx: ClassMethodDecoratorContext) {
return async function (this: T, ...args: A): Promise<R> {
for (let attempt = 1; ; attempt++) {
try {
return await method.call(this, ...args);
} catch (err) {
console.log(` ${String(ctx.name)} attempt ${attempt} failed: ${(err as Error).message}`);
if (attempt === times) throw err;
}
}
};
};
}
function timed<T, A extends unknown[], R>(method: Method<T, A, R>, ctx: ClassMethodDecoratorContext) {
return async function (this: T, ...args: A): Promise<R> {
const start = Date.now();
try {
return await method.call(this, ...args);
} finally {
console.log(` ${String(ctx.name)} took ~${Math.round((Date.now() - start) / 50) * 50}ms`);
}
};
}
class Api {
calls = 0;
@timed
@retry(3)
async fetchUser(id: number): Promise<string> {
await new Promise((r) => setTimeout(r, 50));
if (++this.calls < 3) throw new Error("503");
return `user ${id}`;
}
}
console.log(await new Api().fetchUser(7));
$ npx tsc --noEmit --module nodenext retry.ts
$ npx tsx retry.ts
fetchUser attempt 1 failed: 503
fetchUser attempt 2 failed: 503
fetchUser took ~150ms
user 7
@retry wraps the method first, then @timed wraps the retrying version — so the time covers all three attempts (rounded to 50 ms). Swap the two lines and @timed would wrap a single attempt instead, printing one timing per attempt (Practice 1 shows it). Decorator order is part of the behaviour.
Class Decorators and addInitializer
Every context object has addInitializer, which registers code to run when the class is defined (for class decorators) or when each instance is constructed (for member decorators). A classic use is auto-binding methods:
function bound<This>(method: (this: This, ...args: any[]) => any, ctx: ClassMethodDecoratorContext<This>) {
ctx.addInitializer(function (this: This) {
(this as any)[ctx.name] = method.bind(this);
});
}
const registry: string[] = [];
function register(value: Function, ctx: ClassDecoratorContext) {
ctx.addInitializer(() => registry.push(String(ctx.name)));
}
@register
class Counter {
count = 0;
@bound
increment() {
return ++this.count;
}
decrement() {
return --this.count;
}
}
console.log("registered:", registry);
const c = new Counter();
const inc = c.increment;
console.log(inc(), inc());
const dec = c.decrement;
try {
dec();
} catch (e) {
console.log("unbound:", (e as Error).message);
}
$ npx tsc --noEmit --module nodenext bound.ts
$ npx tsx bound.ts
registered: [ 'Counter' ]
1 2
unbound: Cannot read properties of undefined (reading 'count')
registry was filled when the class definition ran, before any instance existed — handy for plugin or route registration. The detached increment keeps working because the initializer bound it to each instance; the undecorated decrement loses this.
Accessor and Field Decorators
Standard decorators can’t intercept plain fields’ reads and writes, but the accessor keyword creates a getter/setter pair backed by private storage, and an accessor decorator can wrap both.
function range(min: number, max: number) {
return function <This>(
target: ClassAccessorDecoratorTarget<This, number>,
ctx: ClassAccessorDecoratorContext<This, number>,
): ClassAccessorDecoratorResult<This, number> {
const check = (v: number) => {
if (v < min || v > max) throw new RangeError(`${String(ctx.name)} must be ${min}..${max}, got ${v}`);
return v;
};
return {
set(value) { target.set.call(this, check(value)); },
init: check,
};
};
}
function trimmed<This>(_: undefined, ctx: ClassFieldDecoratorContext<This, string>) {
return (initial: string) => initial.trim();
}
class Product {
@trimmed name = " Widget ";
@range(0, 10_000) accessor price = 10;
}
const p = new Product();
p.price = 99.99;
console.log(JSON.stringify(p.name), p.price);
try {
p.price = 99_999;
} catch (e) {
console.log((e as Error).message, "- price still", p.price);
}
$ npx tsc --noEmit --module nodenext accessor.ts
$ npx tsx accessor.ts
"Widget" 99.99
price must be 0..10000, got 99999 - price still 99.99
init validates the initial value too, so accessor price = -5 would throw at construction. A field decorator gets undefined as its first argument — there’s nothing to wrap yet — and can only return a function that transforms the initial value; later assignments to name are not intercepted.
Legacy Decorators (experimentalDecorators)
Angular, NestJS, TypeORM and class-validator still use TypeScript’s older decorator system. It has a different signature (target, propertyKey, descriptor), adds parameter decorators, and — with emitDecoratorMetadata — records constructor parameter types at runtime. Mixing the two systems isn’t possible, so it lives in its own project:
{
"compilerOptions": {
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
import "reflect-metadata";
const providers = new Map<unknown, unknown>();
function Injectable(): ClassDecorator {
return (target) => { providers.set(target, undefined); };
}
function resolve<T>(Ctor: new (...args: any[]) => T): T {
const deps: (new () => unknown)[] = Reflect.getMetadata("design:paramtypes", Ctor) ?? [];
console.log(`resolving ${Ctor.name}(${deps.map((d) => d.name).join(", ")})`);
return new Ctor(...deps.map((d) => resolve(d)));
}
@Injectable()
class Logger {
log(msg: string) { console.log(`[log] ${msg}`); }
}
@Injectable()
class UserService {
constructor(private logger: Logger) {}
create(name: string) { this.logger.log(`created ${name}`); }
}
resolve(UserService).create("Ada");
$ npx tsc -p legacy
$ node legacy/dist/di.js
resolving UserService(Logger)
resolving Logger()
[log] created Ada
$ grep -n "design:paramtypes" legacy/dist/di.js
16: const deps = Reflect.getMetadata("design:paramtypes", Ctor) ?? [];
35: __metadata("design:paramtypes", [Logger])
emitDecoratorMetadata made the compiler write __metadata("design:paramtypes", [Logger]) into the output for every decorated class — the one place TypeScript types survive to runtime. That’s how Nest and Angular know what to inject without being told. It only works for classes (an interface has no runtime value to record), and it requires the reflect-metadata polyfill.
A Worked Example: Declarative Validation
A class-validator-style system, using standard decorators: each decorator records a rule in context.metadata, and a validate function reads the rules back.
(Symbol as { metadata?: symbol }).metadata ??= Symbol("Symbol.metadata");
type Rule = (value: unknown) => string | null;
function rule(check: Rule) {
return (_: undefined, ctx: ClassFieldDecoratorContext) => {
const rules = ((ctx.metadata.rules ??= {}) as Record<string, Rule[]>);
(rules[String(ctx.name)] ??= []).push(check);
};
}
const MinLength = (n: number) => rule((v) => (typeof v === "string" && v.length >= n ? null : `must be at least ${n} characters`));
const IsEmail = rule((v) => (typeof v === "string" && /^[^\s@]+@[^\s@]+$/.test(v) ? null : "must be an email"));
function validate(obj: object): string[] {
const meta = (obj.constructor as unknown as Record<symbol, { rules?: Record<string, Rule[]> } | undefined>)[Symbol.metadata];
return Object.entries(meta?.rules ?? {}).flatMap(([field, rules]) =>
rules.map((r) => r((obj as Record<string, unknown>)[field])).filter((m) => m !== null).map((m) => `${field} ${m}`),
);
}
class CreateUserDto {
@MinLength(2) name = "";
@IsEmail email = "";
constructor(init: Partial<CreateUserDto>) { Object.assign(this, init); }
}
console.log(validate(new CreateUserDto({ name: "Ada", email: "ada@example.com" })));
console.log(validate(new CreateUserDto({ name: "A", email: "nope" })));
$ npx tsc --noEmit --module nodenext validation.ts
validation.ts(7,21): error TS18048: 'ctx.metadata' is possibly 'undefined'.
validation.ts(16,118): error TS2550: Property 'metadata' does not exist on type 'SymbolConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'esnext' or later.
$ npx tsc --noEmit --module nodenext --target esnext validation.ts
$ npx tsx validation.ts
[]
[ 'name must be at least 2 characters', 'email must be an email' ]
context.metadata is an object shared by every decorator on the class and attached to the class as Class[Symbol.metadata] — the standard replacement for reflect-metadata. It’s new enough that two things lag behind. TypeScript 6’s default lib doesn’t declare Symbol.metadata (and so types ctx.metadata as possibly undefined), so the file only type-checks with --target esnext, or "lib": ["esnext"] in a config. And Node 22 doesn’t define Symbol.metadata at runtime, so the first line polyfills it; without that, the metadata is never attached and validate finds no rules.
Practice
1. Swap the order of @timed and @retry(3) in retry.ts. How many timing lines appear, and why?
Solution
type Method<T, A extends unknown[], R> = (this: T, ...args: A) => Promise<R>;
function retry(times: number) {
return function <T, A extends unknown[], R>(method: Method<T, A, R>, ctx: ClassMethodDecoratorContext) {
return async function (this: T, ...args: A): Promise<R> {
for (let attempt = 1; ; attempt++) {
try {
return await method.call(this, ...args);
} catch (err) {
console.log(` ${String(ctx.name)} attempt ${attempt} failed`);
if (attempt === times) throw err;
}
}
};
};
}
function timed<T, A extends unknown[], R>(method: Method<T, A, R>, ctx: ClassMethodDecoratorContext) {
return async function (this: T, ...args: A): Promise<R> {
const start = Date.now();
try {
return await method.call(this, ...args);
} finally {
console.log(` ${String(ctx.name)} took ~${Math.round((Date.now() - start) / 50) * 50}ms`);
}
};
}
class Api {
calls = 0;
@retry(3)
@timed
async fetchUser(id: number): Promise<string> {
await new Promise((r) => setTimeout(r, 50));
if (++this.calls < 3) throw new Error("503");
return `user ${id}`;
}
}
console.log(await new Api().fetchUser(7));
$ npx tsx practice-1.ts
fetchUser took ~50ms
fetchUser attempt 1 failed
fetchUser took ~50ms
fetchUser attempt 2 failed
fetchUser took ~50ms
user 7
Three: @timed is now nearest the method, so it wraps a single attempt, and @retry calls that timed version three times.
2. Write a @memo method decorator that caches results by the first argument, and show a slow method only running once per input.
Solution
function memo<This, A, R>(method: (this: This, arg: A) => R, _ctx: ClassMethodDecoratorContext) {
const cache = new Map<A, R>();
return function (this: This, arg: A): R {
if (!cache.has(arg)) cache.set(arg, method.call(this, arg));
return cache.get(arg)!;
};
}
class MathService {
runs = 0;
@memo
fib(n: number): number {
this.runs++;
return n < 2 ? n : this.fib(n - 1) + this.fib(n - 2);
}
}
const m = new MathService();
console.log(m.fib(40), "computed in", m.runs, "runs");
$ npx tsc --noEmit --module nodenext practice-2.ts
$ npx tsx practice-2.ts
102334155 computed in 41 runs
41 runs instead of hundreds of millions, because the recursive this.fib calls go through the decorated method too. Note the cache is shared by every instance of the class, since the decorator runs once per class definition — put it in an addInitializer if it must be per-instance.
3. Add a @range(0, 150) accessor age to Product-style class Person and show that an invalid initial value throws at construction.
Solution
function range(min: number, max: number) {
return function <This>(
target: ClassAccessorDecoratorTarget<This, number>,
ctx: ClassAccessorDecoratorContext<This, number>,
): ClassAccessorDecoratorResult<This, number> {
const check = (v: number) => {
if (v < min || v > max) throw new RangeError(`${String(ctx.name)} must be ${min}..${max}, got ${v}`);
return v;
};
return { set(value) { target.set.call(this, check(value)); }, init: check };
};
}
class Person {
@range(0, 150) accessor age: number;
constructor(age: number) { this.age = age; }
}
class Broken {
@range(0, 150) accessor age = -1;
}
console.log(new Person(36).age);
try {
new Broken();
} catch (e) {
console.log((e as Error).message);
}
$ npx tsc --noEmit --module nodenext practice-3.ts
$ npx tsx practice-3.ts
36
age must be 0..150, got -1
Person has no initialiser, so init sees undefined, which isn’t below 0 or above 150 — that’s why it passes; the constructor’s assignment then goes through the checked set. Broken’s -1 fails in init.