Property Decorators Explained: A Practical Guide for TypeScript Developers
Introduction
Property decorators are a powerful metaprogramming feature in TypeScript that let you annotate and modify properties at design time and augment runtime behavior without changing the property usage throughout your codebase. For intermediate developers who are comfortable with TypeScript classes and basic decorators, property decorators unlock elegant patterns for validation, lazy initialization, serialization, dependency injection, and more.
In this deep tutorial you'll learn what property decorators are, how they differ from other decorators (method, parameter, accessor, class), and how to create robust, reusable property decorator factories. We'll walk through the decorator signature and lifecycle, show practical examples (validation, default values, lazy getters, metadata-based serialization), and highlight patterns for composition and inheritance. You'll also see how to use decorators safely with TypeScript's emit options, avoid common pitfalls, and optimize performance in production code.
By the end of this article you will be able to:
- Understand the property decorator signature and when it runs.
- Implement decorator factories that accept options and maintain types.
- Use decorators with inheritance and interfaces in a maintainable way.
- Combine decorators with reflective metadata for advanced scenarios.
- Apply best practices and avoid runtime surprises.
If you want to brush up on classes and inheritance before diving in, our primer on Introduction to Classes in TypeScript: Properties and Methods and the guide on Class Inheritance: Extending Classes in TypeScript are good starting points.
Background & Context
Decorators in TypeScript are an experimental language feature modeled after a proposal for JavaScript decorators. They let you attach behavior to classes, methods, accessors, properties, and parameters. Property decorators specifically receive metadata about a class property at definition time and can be used to modify or annotate the property descriptor, inject metadata, or replace the property accessor.
Because property decorators operate at the class definition phase, they can be used to centralize cross-cutting concerns like validation rules or serialization hints without scattering boilerplate across your application. When used carefully, decorators produce clean and declarative code that's easy to read and maintain. However, improper use (side-effects in decorators, relying on non-deterministic execution order) can make debugging harder, so we'll cover best practices and patterns to keep decorators predictable.
If you're interested in how decorators interact with access control and encapsulation, review our article on Access Modifiers: public, private, and protected — An In-Depth Tutorial.
Key Takeaways
- Property decorators run during class definition and receive the target and property key.
- Decorator factories allow configurable decorators with typed options.
- Use Reflect metadata for advanced scenarios requiring types at runtime (enable emitDecoratorMetadata).
- Avoid heavy side effects — prefer idempotent initialization and lazy patterns.
- Combine decorators with inheritance and interface-based design carefully.
- Use decorators for validation, serialization, DI, and caching with clear responsibility boundaries.
Prerequisites & Setup
Before using decorators in TypeScript, ensure the following:
- TypeScript configured with "experimentalDecorators": true in tsconfig.json.
- If using runtime type metadata, also enable "emitDecoratorMetadata": true and install reflect-metadata: npm install reflect-metadata, and import 'reflect-metadata' at application entry.
- Solid familiarity with classes, property accessors (get/set), and object descriptors — see Implementing Interfaces with Classes if you need a refresher.
A minimal tsconfig snippet:
{
"compilerOptions": {
"target": "ES2017",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Now we'll dig into concrete, actionable sections.
Main Tutorial Sections
1) Property Decorator Signature and Basics
A property decorator accepts two arguments: the prototype (or constructor for static properties) and the property name. It cannot directly receive the property's initializer value. The basic signature:
function MyPropDecorator(target: Object, propertyKey: string | symbol) {
// run at class definition
}Example usage:
class User {
@MyPropDecorator
public name: string;
}Note: Because property decorators don't receive a descriptor, to alter property behavior (like adding a getter/setter), you'll typically define a new property descriptor using Object.defineProperty inside the decorator.
2) Writing a Decorator Factory with Options
Most real-world decorators need configuration. A decorator factory returns the decorator:
function Default(value: any) {
return function (target: any, key: string) {
const privateKey = `__${String(key)}`;
Object.defineProperty(target, key, {
get() {
return this[privateKey] !== undefined ? this[privateKey] : value;
},
set(v) {
this[privateKey] = v;
},
enumerable: true,
configurable: true
});
};
}
class Config {
@Default(8080)
port!: number;
}
const c = new Config();
console.log(c.port); // 8080This pattern wraps the property with an accessor and a backing private field. Use Symbols or unique keys to avoid collisions.
3) Using Reflect Metadata for Type-Aware Decorators
If you enable "emitDecoratorMetadata", TypeScript emits design-time information accessible via Reflect. This is useful for creating type-aware decorators (e.g., serializer, DI):
import 'reflect-metadata';
function TypeHint() {
return function (target: any, key: string) {
const type = Reflect.getMetadata('design:type', target, key);
console.log(`${key} type is`, type && type.name);
};
}
class Example {
@TypeHint()
list!: string[]; // design:type -> Array
}Caveat: emitted types are constructor functions (e.g., Array, Object) — for generics/union types you may need explicit options. For working with types and advanced conditional utilities, our pieces on Using infer with Functions in Conditional Types and Using infer with Objects in Conditional Types — Practical Guide can provide background on type-level extraction patterns.
4) Validation Decorator Example
Create a simple validation decorator that registers constraints on a class and runs validation at runtime:
const validators = new WeakMap<any, Record<string, Function[]>>();
function Required() {
return function (target: any, key: string) {
const map = validators.get(target) || {};
map[key] = map[key] || [];
map[key].push((v: any) => v !== null && v !== undefined);
validators.set(target, map);
};
}
function validate(obj: any) {
const map = validators.get(Object.getPrototypeOf(obj));
if (!map) return true;
return Object.keys(map).every(k => map[k].every(fn => fn(obj[k])));
}
class UserForm {
@Required()
name?: string;
}
const f = new UserForm();
console.log(validate(f)); // falseThis approach keeps validation rules declarative. For more robust solutions, you might integrate with class-transformer or a validation library.
5) Serialization Hints and Mapping
Decorators are great for marking properties for serialization and mapping field names. Example:
const metaKey = Symbol('serialize:fields');
function SerializeAs(name?: string) {
return function (target: any, key: string) {
const fields = Reflect.getOwnMetadata(metaKey, target) || {};
fields[key] = name || key;
Reflect.defineMetadata(metaKey, fields, target);
};
}
function toJson(instance: any) {
const proto = Object.getPrototypeOf(instance);
const fields = Reflect.getOwnMetadata(metaKey, proto) || {};
const out: any = {};
for (const k of Object.keys(fields)) {
out[fields[k]] = instance[k];
}
return out;
}
class Person {
@SerializeAs('first_name')
firstName!: string;
}If you’re transforming types or shaping objects, consider patterns from Recursive Mapped Types for Deep Transformations in TypeScript to manage complex mappings at the type level.
6) Lazy Initialization and Caching Decorator
Add a decorator that turns a property into a lazily computed and cached value:
function Lazy(target: any, key: string) {
const privateKey = Symbol(`__${key}`);
const fnKey = Symbol(`__${key}_factory`);
Object.defineProperty(target, key, {
get() {
if (!(fnKey in this)) return undefined;
if (!(privateKey in this)) {
this[privateKey] = this[fnKey]();
}
return this[privateKey];
},
set(factory: () => any) {
Object.defineProperty(this, fnKey, { value: factory, enumerable: false });
},
configurable: true,
enumerable: true
});
}
class BigData {
@Lazy
compute!: number;
}
const b = new BigData();
b.compute = () => expensiveComputation();
console.log(b.compute); // triggers computation onceThis pattern avoids upfront cost and keeps object API clean.
7) Decorators & Inheritance — Merging Metadata
Decorators need careful handling when classes are extended. Metadata stored on prototypes can be merged on subclass creation:
function Tag(tag: string) {
return function (target: any, key: string) {
const proto = target;
proto.__tags = proto.__tags || {};
proto.__tags[key] = (proto.__tags[key] || []).concat(tag);
};
}
class Base { @Tag('base') id!: number; }
class Derived extends Base { @Tag('derived') id!: number; }
// When reading tags, traverse the prototype chain to merge tags
function collectTags(instance: any) {
let proto = Object.getPrototypeOf(instance);
const tags: Record<string, string[]> = {};
while (proto && proto !== Object.prototype) {
if (proto.__tags) {
for (const k of Object.keys(proto.__tags)) {
tags[k] = (tags[k] || []).concat(proto.__tags[k]);
}
}
proto = Object.getPrototypeOf(proto);
}
return tags;
}When designing decorators for class hierarchies, document whether metadata is merged or overridden. For deeper discussions on class hierarchies and design patterns, review Abstract Classes: Defining Base Classes with Abstract Members and Implementing Interfaces with Classes.
8) Decorating Private vs Public Properties
Decorators receive the prototype for instance properties, even for private fields (but note: true ECMAScript private fields (#name) are not reachable). For TypeScript "private" fields, the decorator runs but cannot access the initializer. Use symbol-backed private fields or closures to emulate privacy where needed.
Example: decorating a property declared as private in TypeScript:
class A {
@Default(0)
private count!: number;
}Keep in mind that TypeScript's privacy is compile-time; decorators operate on emitted JS prototypes.
9) Composing Decorators and Order of Execution
Multiple decorators on the same property execute bottom-up (the decorator closest to the property runs first). Use decorator factories that return idempotent functions to avoid order-sensitive bugs:
function D1() { return (t: any, k: string) => console.log('D1', k); }
function D2() { return (t: any, k: string) => console.log('D2', k); }
class X { @D1() @D2() prop!: string; }
// logs: D2 prop
// D1 propDocument expectations and avoid side effects that rely on execution order.
10) Interoperability with Type-Level Utilities
Decorators operate at runtime, while TypeScript type utilities operate at compile time. If you build decorator libraries that surface typed APIs, you may combine runtime decorators with static helper types (e.g., mapped types for serialized shapes). Learn how mapped types and key remapping can help design types that mirror decorator-driven runtime behavior — see Advanced Mapped Types: Key Remapping with Conditional Types and Advanced Mapped Types: Modifiers (+/- readonly, ?) for patterns to keep types aligned with decorator semantics.
Advanced Techniques
Advanced decorator techniques include: decorator composition helpers, metadata merging strategies, and generating declaration-time sidecar data (e.g., schema generation). Use a small runtime registry (WeakMap keyed by prototype) to avoid memory leaks and support GC. For type-aware decorators, combine Reflect metadata with explicit options to represent complex generics. If you need to compute types at compile time for advanced serialization, study type-level utilities like Literal Types: Exact Values as Types and union/intersection modeling from Union Types: Allowing a Variable to Be One of Several Types and Intersection Types: Combining Multiple Types (Practical Guide) to design consistent APIs.
Performance tips:
- Avoid heavy work in decorator bodies; do registration, not execution-heavy logic.
- Cache resolved metadata per prototype rather than recomputing on each instance.
- Use Symbols for private backing keys to avoid name collisions and leakages.
Best Practices & Common Pitfalls
Dos:
- Keep decorators declarative and side-effect-free where possible.
- Use decorator factories for configurable behavior and proper typing.
- Store metadata in WeakMaps or on prototypes using Symbols to avoid collisions.
- Document how decorators behave across inheritance and whether they merge or override metadata.
Don'ts:
- Don’t perform expensive work at decoration time — decoration happens on module load/class definition.
- Don’t rely on decorator execution order unless explicitly controlled.
- Avoid assuming emitted Reflect metadata will fully capture complex generic types.
Troubleshooting:
- If Reflect.getMetadata returns undefined, ensure you imported 'reflect-metadata' and enabled emitDecoratorMetadata.
- If the decorated property is undefined at runtime unexpectedly, check for descriptor conflicts or multiple decorators that redefine the property.
- For unexpected behavior with private fields, remember that ECMAScript private fields (#name) are not accessible to decorators.
Real-World Applications
Property decorators are commonly used for:
- Validation (required fields, ranges) — keeps business logic declarative.
- Serialization/deserialization mappings (field renaming, ignoring fields).
- Dependency injection tokens or lazy injection registration.
- Caching and memoization for expensive computed properties.
- ORM-style mapping where decorators annotate columns and relationships.
For example, ORMs like TypeORM use decorators to describe table columns and relationships. When building libraries that rely on decorators, make sure your runtime metadata scheme is stable and documented.
If you need to reconcile decorator-driven runtime behavior with static types, reviewing Differentiating Between Interfaces and Type Aliases in TypeScript will help you choose the right static shapes for your decorated classes.
Conclusion & Next Steps
Property decorators are a flexible way to introduce cross-cutting concerns and declarative metadata into TypeScript classes. Start small with well-scoped decorators (Default, Required, SerializeAs), keep decorator bodies idempotent, and prefer metadata registration over heavy initialization. Next, explore combining decorators with runtime registries, or build a small library that exposes typed helper types matching your decorators. Refresh your knowledge of class features and inheritance where decorators will be applied by reading Class Inheritance: Extending Classes in TypeScript and Access Modifiers: public, private, and protected — An In-Depth Tutorial.
Enhanced FAQ
Q1: When exactly is a property decorator executed? A1: A property decorator runs at class definition time — when the class is evaluated (module load). It receives the target prototype (or constructor for static properties) and property key. It does not receive the property descriptor or initializer value directly.
Q2: Can I read the initial value of a decorated property inside the decorator? A2: No. At decoration time you don’t get the instance nor the initializer’s resulting value. To capture or replace the initializer, wrap the property with get/set and use a backing key (Symbol or prefixed name) to store the value on instances.
Q3: How do decorators behave with inheritance? Do child classes inherit decorated behavior? A3: The decorator runs when the class is defined. If a base class decorator registers metadata on the prototype, subclasses inherit that prototype chain at runtime, but if a subclass redeclares the property, its decorator runs for that subclass. When designing libraries, explicitly decide whether to merge prototype metadata across the chain and implement merging functions to collect the final metadata.
Q4: Are ECMAScript private fields (#name) accessible to property decorators? A4: No. Decorators operate on class prototypes and do not have access to ECMAScript private fields. TypeScript "private" members are only compile-time constraints and are accessible at runtime via emitted names; decorators can operate on them but be cautious with name mangling.
Q5: Should I enable emitDecoratorMetadata? What does it do? A5: Enabling emitDecoratorMetadata causes TypeScript to emit design-time type metadata for decorated declarations (e.g., design:type). This can be helpful for DI, serialization, or validation that requires the constructor of the property type. However, emitted metadata is limited (constructor functions only) and may not capture generic parameter details.
Q6: How do multiple decorators on the same property resolve order? A6: When multiple decorators are applied to a single declaration, they are evaluated in bottom-up order (the decorator closest to the property is called first). For decorator factories, the outermost factory is evaluated first but the returned decorator is applied later; this can be confusing—write idempotent decorators and document intended ordering.
Q7: Can decorators change a property’s TypeScript type? A7: No — decorators operate at runtime and cannot change compile-time TypeScript types. If your decorator changes runtime shape, provide accompanying type-level utilities (mapped types or helper types) so static types remain in sync. For designing type-level helpers, review Advanced Mapped Types: Key Remapping with Conditional Types.
Q8: How should I store metadata to avoid memory leaks? A8: Use WeakMap keyed by prototypes or constructors so metadata does not prevent garbage collection. Avoid storing metadata on global arrays keyed by object identity unless you have a solid lifecycle management plan.
Q9: Are there performance concerns with many decorators? A9: Yes — decorating many classes/properties may add startup overhead since decoration logic runs at module load. Keep decorator bodies lightweight (register metadata, avoid heavy computation), and use lazy initialization strategies to defer work until needed.
Q10: How do I test decorator behavior effectively? A10: Keep decorators small and testable by exposing the registration APIs (e.g., registry reading functions). Write unit tests that verify metadata is registered correctly, and integration tests to confirm runtime behavior (validation, serialization). Mock Reflect.getMetadata or import a fresh module state when testing decorator initialization side effects.
Q11: Can property decorators be used with functional programming patterns or only classes? A11: Decorators apply to class-based declarations. For FP-centric code, prefer pure functions and higher-order utilities rather than decorators. If you need both, isolate decorator usage to a thin OO layer that interops with functional internals.
Q12: How do decorators interact with TypeScript utility types like Record, ReturnType, or Parameters?
A12: Runtime decorators don't directly interact with type utilities, but you can design typed helper types that mirror your runtime metadata. For example, if decorators mark keys to serialize, you can create a mapped type using Utility Type: Record<K, T> for Dictionary Types to represent the serialized shape. You can also use ReturnType and Parameters in helper functions that generate factories that decorators reference — for more on those utilities, see Utility Type: ReturnType
If you still have questions or want a working example tailored to your codebase (e.g., a validation decorator wired to your existing form models), share a minimal example and I can help adapt a decorator pattern that integrates safely with your architecture.
