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— alwaysundefinedfor 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: itsname, whether it isstaticorprivate, and helper methods likeaddInitializer.
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
returnthe (possibly transformed) value from your initializer-replacement function; forgetting the return silently sets the field toundefined. - Use
context.addInitializerfor 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 tounknowneverywhere. - Avoid
anyin a decorator’s signature; use generics orunknownwith 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
@positivefield decorator (no factory needed) that forces any decoratednumberfield to be stored as0if its initial value is negative, and leaves it unchanged otherwise. - Write a
@trimfield decorator forstringfields that removes leading and trailing whitespace from the field’s initial value before it is stored. - Extend the
serializableexample from this lesson so that adescribe(instance)function returns a comma-separated list of the decorated field names (not their values) for any instance — reusing the sameWeakMapregistry 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(alwaysundefinedfor fields) and acontextobject with the field’s name, kind, and helpers likeaddInitializer. - Returning a function
(initialValue) => Valuelets 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 satisfystrictPropertyInitialization. - These modern decorators require no
experimentalDecoratorsflag and are distinct from the legacy decorator system used by older frameworks.
