TypeScript Property Decorators

A property decorator — called a field decorator in TypeScript’s modern decorator system — is a function you attach to a class field to observe, log, validate, or transform that field’s value every time an instance of the class is created. Instead of repeating the same validation or bookkeeping logic inside every constructor, you write it once as a decorator and reuse it across any field, in any class. This lesson covers the modern, standards-based decorators that TypeScript ships with by default (TypeScript 5.0 and later), which differ from the older “experimental” decorators still used by libraries like Angular or NestJS.

Overview: How Field Decorators Work

A field decorator is just a function. You attach it to a property declaration with an @ prefix, placed directly above (or on the same line as) the field. When the class is defined — not when an instance is created — TypeScript calls your decorator function once for each decorated field, passing it two arguments:

  • value — always undefined for a field decorator. Fields don’t have a value yet at class-definition time; the actual value only exists once an instance is constructed.
  • context — an object describing the field: its name, whether it is static or private, and helper methods like addInitializer.

Your decorator function can optionally return a new function with the signature (initialValue) => newValue. If it does, TypeScript arranges for that returned function to run automatically every time a new instance is constructed, receiving the field’s initializer expression as initialValue and using whatever it returns as the field’s real starting value. This is how you validate, clamp, transform, or log a field’s value without touching the constructor.

This differs sharply from the legacy experimentalDecorators system (enabled via a tsconfig.json flag) used by older frameworks, where a property decorator’s signature was simply (target: any, propertyKey: string) => void and it could not easily intercept or replace the field’s value. The modern decorators described here are part of an approved ECMAScript proposal, work without any compiler flag, and are the version you should reach for in new code.

Syntax

function fieldDecorator<This, Value>(
  value: undefined,
  context: ClassFieldDecoratorContext<This, Value>
): ((this: This, initialValue: Value) => Value) | void {
  console.log(`Decorating field "${String(context.name)}"`);
}

class Widget {
  @fieldDecorator
  label: string = "Button";
}

Each part of the signature matters:

Part Meaning
value: undefined Always undefined for fields — there is no instance yet.
context.kind Always the string literal "field" for property decorators.
context.name The field’s name, typed string | symbol.
context.static / context.private Booleans telling you if the field is static or a #private field.
context.addInitializer(fn) Registers fn to run once per instance, after all fields initialize.
Return value Either void (leave the field alone) or a function (initialValue) => Value that replaces the stored value.

Declaring the decorator as generic over <This, Value> lets TypeScript infer the exact field type at each use site, so fieldDecorator above works correctly whether it decorates a string, a number, or any other type.

Examples

Example 1: Logging when a field initializes

function logInit<This, Value>(
  value: undefined,
  context: ClassFieldDecoratorContext<This, Value>
) {
  const fieldName = String(context.name);
  console.log(`Decorator applied to field: ${fieldName}`);
  return function (this: This, initialValue: Value): Value {
    console.log(`Field "${fieldName}" initialized with:`, initialValue);
    return initialValue;
  };
}

class Product {
  @logInit
  name: string = "Keyboard";

  @logInit
  price: number = 49.99;
}

const p = new Product();
console.log(p.name, p.price);

Output:

Decorator applied to field: name
Decorator applied to field: price
Field "name" initialized with: Keyboard
Field "price" initialized with: 49.99
Keyboard 49.99

Notice the two distinct phases. The “Decorator applied” lines print immediately when the Product class itself is defined — before any instance exists — because that’s when TypeScript calls logInit for each field. The “initialized with” lines only print later, when new Product() actually runs and each field’s initializer function executes.

Example 2: Clamping a value with a decorator factory

function min(minimum: number) {
  return function <This>(
    value: undefined,
    context: ClassFieldDecoratorContext<This, number>
  ) {
    return function (this: This, initialValue: number): number {
      return initialValue < minimum ? minimum : initialValue;
    };
  };
}

class Account {
  @min(0)
  balance: number = -50;
}

const acc = new Account();
console.log(acc.balance);

Output:

0

min(0) is a decorator factory: calling min(0) returns the actual field decorator, which closes over minimum. The returned initializer function runs when Account is constructed, sees that -50 is below the minimum, and substitutes 0 instead. The field’s declared type still reads as number — the enforcement happens purely at runtime, but the decorator’s type signature guarantees it can only be applied to number fields.

Example 3: Building a serializable-fields registry

const serializableFields = new WeakMap<object, string[]>();

function serializable<This extends object>(
  value: undefined,
  context: ClassFieldDecoratorContext<This, unknown>
) {
  const fieldName = String(context.name);
  context.addInitializer(function (this: This) {
    const existing = serializableFields.get(this) ?? [];
    existing.push(fieldName);
    serializableFields.set(this, existing);
  });
}

class User {
  @serializable
  id: number = 1;

  @serializable
  email: string = "a@example.com";

  password: string = "secret";
}

function toJSON(instance: object): string {
  const fields = serializableFields.get(instance) ?? [];
  const result: Record<string, unknown> = {};
  for (const field of fields) {
    result[field] = (instance as Record<string, unknown>)[field];
  }
  return JSON.stringify(result);
}

const user = new User();
console.log(toJSON(user));

Output:

{"id":1,"email":"a@example.com"}

This is close to how real ORMs and serialization libraries use field decorators: @serializable doesn’t transform the value at all — it uses context.addInitializer to register, per instance, that this field name should be included whenever toJSON runs. Because password was never decorated, it’s silently excluded from the output.

Under the Hood

At compile time, TypeScript checks your decorator function’s signature against exactly what the decorated field requires: a first parameter of type undefined and a second parameter assignable from ClassFieldDecoratorContext<This, Value> for that field’s actual This (the containing class) and Value (the field’s type). If the shapes don’t line up, you get a compile error before any code runs.

At the JavaScript level, decorators are not erased the way type annotations are. A type annotation like : string disappears completely from the emitted JavaScript — it exists purely for the compiler. A decorator, by contrast, is a real function that TypeScript compiles into an actual function call: the emitted JavaScript calls your decorator against each field when the class is defined, and calls the returned initializer function against each new instance. Only the type information (the generics, the ClassFieldDecoratorContext interface itself) is erased — the runtime behavior of the decorator survives intact.

Evaluation and application order

If you stack more than one decorator on the same field, TypeScript evaluates the decorator expressions top-to-bottom, but calls (applies) them bottom-to-top — the decorator written closest to the field runs first. This mirrors how legacy method decorators behaved and is worth remembering when a field carries several decorators that each wrap the initializer.

Common Mistakes

Mistake 1: Treating value as the field’s value

The first parameter of a field decorator is always undefined — it is not the field’s eventual value. Trying to use it directly fails to compile:

function shout(value: undefined, context: ClassFieldDecoratorContext) {
  console.log(value.toUpperCase());
}

class Message {
  @shout
  text: string = "hello";
}

tsc reports: Property 'toUpperCase' does not exist on type 'undefined'. (TS2339) — because value is genuinely typed as undefined, strict null checking rejects calling a string method on it.

The fix is to work with the value inside the returned initializer function, which receives the field’s real value once an instance is constructed:

function shout<This>(
  value: undefined,
  context: ClassFieldDecoratorContext<This, string>
) {
  return function (this: This, initialValue: string): string {
    console.log(initialValue.toUpperCase());
    return initialValue;
  };
}

class Message {
  @shout
  text: string = "hello";
}

new Message();

Output:

HELLO

Mistake 2: Assuming a decorator satisfies strictPropertyInitialization

It’s tempting to think that decorating a field (say, to register it for an ORM) means TypeScript no longer needs the field to have an initializer. It still does — the compiler has no way of knowing your decorator will supply a value at runtime:

function Column(value: undefined, context: ClassFieldDecoratorContext) {
  console.log(`Column registered: ${String(context.name)}`);
}

class Row {
  @Column
  id: number;
}

tsc reports: Property 'id' has no initializer and is not definitely assigned in the constructor. (TS2564) — this check comes from strictPropertyInitialization, which is part of --strict.

Fix it with a default value, or, if you genuinely expect something outside the constructor to assign it, a definite assignment assertion (!):

function Column(value: undefined, context: ClassFieldDecoratorContext) {
  console.log(`Column registered: ${String(context.name)}`);
}

class Row {
  @Column
  id!: number;
}

const row = new Row();
row.id = 42;
console.log(row.id);

Output:

Column registered: id
42

Best Practices

  • Prefer the modern, context-based decorators shown here for new code — they need no compiler flag and are the standardized syntax going forward.
  • Always return the (possibly transformed) value from your initializer-replacement function; forgetting the return silently sets the field to undefined.
  • Use context.addInitializer for cross-cutting bookkeeping (registries, metadata) rather than trying to read or mutate an instance from inside the decorator body itself — the decorator body runs once, at class-definition time, before any instance exists.
  • Make decorators generic over <This, Value> so TypeScript infers the exact field type at each call site instead of falling back to unknown everywhere.
  • Avoid any in a decorator’s signature; use generics or unknown with explicit, narrow casts so mistakes are still caught at compile time.
  • Keep decorators focused on one concern each (logging, validation, registration) and compose several small decorators rather than one that does everything.
  • Remember that fields, unlike methods, don’t have a value until construction — never assume other fields on the same instance are already initialized inside a decorator’s initializer function.

Practice Exercises

  • Write a @positive field decorator (no factory needed) that forces any decorated number field to be stored as 0 if its initial value is negative, and leaves it unchanged otherwise.
  • Write a @trim field decorator for string fields that removes leading and trailing whitespace from the field’s initial value before it is stored.
  • Extend the serializable example from this lesson so that a describe(instance) function returns a comma-separated list of the decorated field names (not their values) for any instance — reusing the same WeakMap registry pattern.

Summary

  • A property (field) decorator is a function called once, at class-definition time, for each decorated field — not per instance.
  • It receives value (always undefined for fields) and a context object with the field’s name, kind, and helpers like addInitializer.
  • Returning a function (initialValue) => Value lets you validate, transform, or log the field’s value every time a new instance is constructed.
  • Decorator syntax survives into the emitted JavaScript as real function calls — only the TypeScript type information around it is erased.
  • Under --strict, decorated fields still need an initializer or a definite assignment assertion (!) to satisfy strictPropertyInitialization.
  • These modern decorators require no experimentalDecorators flag and are distinct from the legacy decorator system used by older frameworks.