Introduction
TypeScript 5.0, released in March 2023, was a major milestone that brought the language to its fifth major version. The headline feature was standard decorators—finally implementing the TC39 Stage 3 decorator proposal after years of experimental decorator support. But the release packed much more: const type parameters, multiple extends in tsconfig.json, enum improvements, and numerous quality-of-life enhancements that refined the TypeScript development experience.
The transition from experimental to standard decorators was significant. The experimental decorators had been available since TypeScript 1.5 (2015), and many major frameworks—Angular, MobX, TypeORM, and NestJS—had built their entire decorator-based APIs around them. Standard decorators have a different syntax and semantics, requiring careful migration planning. TypeScript 5.0 supported both simultaneously, giving the ecosystem time to transition.
This guide covers every major feature in TypeScript 5.0, with practical examples, migration strategies from experimental decorators, and the patterns that make these features useful in production.
Understanding Standard Decorators
What Are Decorators?
Decorators are a metaprogramming feature that allows you to annotate and modify classes, methods, properties, and parameters at definition time. They provide a clean syntax for cross-cutting concerns like logging, validation, dependency injection, and memoization.
Experimental vs Standard Decorators
The experimental decorators (TypeScript 1.5+) and standard decorators (TC39 Stage 3, TypeScript 5.0) have significant differences:
// Experimental decorators (legacy)
class UserService {
@log
@validate
getUser(@param('id') id: string): User {
return this.db.find(id);
}
}
// Standard decorators (TypeScript 5.0)
class UserService {
@log
@validate
getUser(id: string): User {
return this.db.find(id);
}
}Key differences:
- Parameter decorators removed: Standard decorators don't support parameter decorators
- Different context object: The context API is completely different
- Auto-accessors: New
accessorkeyword for property decorators - Decorator metadata: New metadata API via
Symbol.metadata
Standard Decorator Types
TypeScript 5.0 supports decorators on:
- Classes:
@decorator class Foo {} - Methods:
@decorator method() {} - Properties:
@decorator property: string - Getters:
@decorator get value() {} - Setters:
@decorator set value(v) {} - Auto-accessors:
@decorator accessor value: string
Writing a Standard Decorator
// Method decorator
function log<T>(
target: (this: T, ...args: any[]) => any,
context: ClassMethodDecoratorContext<T>
) {
const methodName = String(context.name);
return function (this: T, ...args: any[]) {
console.log(`Calling ${methodName} with`, args);
const result = target.call(this, ...args);
console.log(`${methodName} returned`, result);
return result;
};
}
// Usage
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3);
// Calling add with [2, 3]
// add returned 5Auto-Accessors
The accessor keyword creates a private field with automatic getter and setter, which decorators can intercept:
function validate<T>(
target: { get: () => T; set: (value: T) => void },
context: ClassFieldDecoratorContext
) {
const name = String(context.name);
return {
get() {
return target.get.call(this);
},
set(value: T) {
if (value === null || value === undefined) {
throw new Error(`${name} cannot be null or undefined`);
}
target.set.call(this, value);
},
};
}
class User {
@validate
accessor name: string = '';
@validate
accessor email: string = '';
}
const user = new User();
user.name = 'John'; // âś…
// user.name = null; // ❌ Error: name cannot be null or undefinedDecorator Metadata
TypeScript 5.0 introduces decorator metadata via Symbol.metadata, allowing decorators to store and retrieve metadata:
function meta(key: string, value: unknown) {
return function (_target: unknown, context: ClassMemberDecoratorContext) {
context.metadata[key] = value;
};
}
class API {
@meta('route', '/users')
@meta('method', 'GET')
getUsers() { /* ... */ }
@meta('route', '/users/:id')
@meta('method', 'GET')
getUser() { /* ... */ }
}
// Access metadata
console.log(API[Symbol.metadata]);
// { route: '/users', method: 'GET' }Decorator Factories
Decorator factories are functions that return decorators with configuration:
function throttle(limit: number) {
return function <T>(
target: (this: T, ...args: any[]) => any,
context: ClassMethodDecoratorContext<T>
) {
let lastCall = 0;
return function (this: T, ...args: any[]) {
const now = Date.now();
if (now - lastCall < limit) {
return;
}
lastCall = now;
return target.call(this, ...args);
};
};
}
class SearchService {
@throttle(300)
search(query: string) {
// This will only be called at most once every 300ms
return this.api.search(query);
}
}Const Type Parameters (Expanded)
Type Inference Without const
TypeScript's type inference typically widens literal types:
// Without const: types are widened
function defineRoutes<T extends Record<string, () => void>>(routes: T): T {
return routes;
}
const routes = defineRoutes({
home: () => {},
about: () => {},
});
// Type: { home: () => void; about: () => void }
// Note: the keys are widened to stringUsing const Type Parameters
The const modifier tells TypeScript to infer the narrowest possible type:
// With const: types are narrow
function defineRoutes<const T extends Record<string, () => void>>(routes: T): T {
return routes;
}
const routes = defineRoutes({
home: () => {},
about: () => {},
});
// Type: { readonly home: () => void; readonly about: () => void }
// The readonly modifier and literal keys are preservedPractical Applications
// Type-safe routing
function createRouter<const T extends Record<string, {
path: string;
component: () => JSX.Element;
}>>(routes: T) {
return {
routes,
navigate: (name: keyof T) => { /* ... */ },
};
}
const router = createRouter({
home: { path: '/', component: () => <Home /> },
dashboard: { path: '/dashboard', component: () => <Dashboard /> },
settings: { path: '/settings', component: () => <Settings /> },
});
router.navigate('home'); // âś… Autocomplete: 'home' | 'dashboard' | 'settings'
// router.navigate('profile'); // ❌ Error
// Type-safe event emitter
function createEmitter<const T extends Record<string, unknown[]>>() {
const listeners = new Map<string, Function[]>();
return {
on<K extends keyof T>(event: K, listener: (...args: T[K]) => void) {
const key = event as string;
if (!listeners.has(key)) listeners.set(key, []);
listeners.get(key)!.push(listener);
},
emit<K extends keyof T>(event: K, ...args: T[K]) {
const key = event as string;
listeners.get(key)?.forEach(fn => fn(...args));
},
};
}
const emitter = createEmitter<{
userLogin: [string, Date];
userLogout: [string];
dataLoaded: [{ id: string; name: string }[]];
}>();
emitter.on('userLogin', (username, timestamp) => {
// username: string, timestamp: Date
console.log(`${username} logged in at ${timestamp}`);
});
emitter.emit('userLogin', 'john', new Date());Multiple Config Extends
Before TypeScript 5.0
You could only extend a single configuration file:
// tsconfig.json (before 5.0)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist"
}
}TypeScript 5.0: Multiple Extends
Now you can extend multiple configuration files:
// tsconfig.json (5.0+)
{
"extends": [
"@company/tsconfig-base",
"./tsconfig.paths.json",
"./tsconfig.strict.json"
],
"compilerOptions": {
"outDir": "./dist"
}
}Practical Configuration Patterns
// tsconfig.base.json - shared base configuration
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
// tsconfig.strict.json - strict type checking
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
// tsconfig.paths.json - path aliases
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}
// tsconfig.json - combines all three
{
"extends": [
"./tsconfig.base.json",
"./tsconfig.strict.json",
"./tsconfig.paths.json"
],
"compilerOptions": {
"outDir": "./dist",
"declaration": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}Override Precedence
When extending multiple configs, later configs override earlier ones:
// tsconfig.json
{
"extends": [
"./tsconfig.base.json", // target: "ES2020"
"./tsconfig.target.json" // target: "ES2022" (overrides base)
]
}Other TypeScript 5.0 Improvements
--verbatimModuleSyntax
TypeScript 5.0 introduces --verbatimModuleSyntax to make module output more predictable:
// With --verbatimModuleSyntax
import type { User } from './types'; // Always removed
import { type User } from './types'; // Always removed
import { User } from './types'; // Always kept (even if only used as type)
// This replaces --importsNotUsedAsValues and --preserveValueImportsenum Improvements
// TypeScript 5.0 allows non-const enum members in const enums
const enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
// Also: enums now work with template literals
type EventName = `${Direction}_EVENT`;
// "UP_EVENT" | "DOWN_EVENT" | "LEFT_EVENT" | "RIGHT_EVENT"Better Type Inference for Constants
// TypeScript 5.0 can infer more precise types for const declarations
const config = {
api: 'https://api.example.com',
timeout: 5000,
retries: 3,
} as const;
// Previously required 'as const', now inferred in some cases
const POINTS = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 2 },
] as const;
// TypeScript 5.0 can narrow types in more contexts
function getPoint(index: 0 | 1 | 2) {
return POINTS[index]; // Precise return type based on index
}@overload Decorator Alternative
// Overload signatures are now supported with decorators
class Formatter {
format(value: string): string;
format(value: number): string;
format(value: Date): string;
format(value: string | number | Date): string {
if (typeof value === 'string') return value.toUpperCase();
if (typeof value === 'number') return value.toFixed(2);
return value.toISOString();
}
}Migration from Experimental to Standard Decorators
Migration Strategy
- Keep experimental decorators during transition — Both can coexist
- Update decorator implementations one at a time — Don't try to migrate everything at once
- Remove parameter decorators — Standard decorators don't support them
- Update context API — The context object has different properties
Migration Example: Logging Decorator
// Experimental (before)
function log(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${key} with`, args);
return original.apply(this, args);
};
return descriptor;
}
// Standard (TypeScript 5.0)
function log<T>(
target: (this: T, ...args: any[]) => any,
context: ClassMethodDecoratorContext<T>
) {
const methodName = String(context.name);
return function (this: T, ...args: any[]) {
console.log(`Calling ${methodName} with`, args);
return target.call(this, ...args);
};
}Migration Example: Validation Decorator
// Experimental (before)
function validate(target: any, key: string) {
let value = target[key];
const getter = () => value;
const setter = (newVal: any) => {
if (newVal === null || newVal === undefined) {
throw new Error(`${key} is required`);
}
value = newVal;
};
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
}
// Standard (TypeScript 5.0)
function validate<T>(
target: { get: () => T; set: (value: T) => void },
context: ClassFieldDecoratorContext
) {
const name = String(context.name);
return {
get() {
return target.get.call(this);
},
set(value: T) {
if (value === null || value === undefined) {
throw new Error(`${name} is required`);
}
target.set.call(this, value);
},
};
}
class User {
@validate
accessor name: string = '';
}Real-World Use Cases
Use Case 1: Dependency Injection Framework
// Standard decorator-based DI
const injectable = () => {
return function <T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext
) {
context.metadata.registered = true;
return target;
};
};
const inject = (token: string) => {
return function <T>(
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (this: any) {
return container.resolve(token);
};
};
};
@injectable()
class UserService {
@inject('Database')
accessor db: Database;
@inject('Logger')
accessor logger: Logger;
}Use Case 2: API Route Decorators
function route(path: string, method: string = 'GET') {
return function <T>(
target: (this: T, ...args: any[]) => any,
context: ClassMethodDecoratorContext<T>
) {
context.metadata.route = path;
context.metadata.method = method;
return target;
};
}
function controller(prefix: string) {
return function <T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext
) {
context.metadata.prefix = prefix;
return target;
};
}
@controller('/api/users')
class UserController {
@route('/')
getUsers() { /* ... */ }
@route('/:id')
getUser() { /* ... */ }
@route('/', 'POST')
createUser() { /* ... */ }
}Use Case 3: Memoization with Const Types
function memoize<Args extends readonly unknown[], Return>(
fn: (...args: Args) => Return
): (...args: Args) => Return {
const cache = new Map<string, Return>();
return (...args: Args) => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
// Type-safe memoization with const inference
const expensiveCalculation = memoize(
(config: { readonly precision: number; readonly algorithm: string }) => {
// Expensive computation
return config.precision * 2;
}
);
const result = expensiveCalculation({ precision: 10, algorithm: 'fast' });
// TypeScript knows the exact parameter typesBest Practices for Production
- Start with standard decorators for new projects — They're the future of decorators in JavaScript
- Keep experimental decorators for existing Angular/NestJS projects — The ecosystem is still migrating
- Use decorator metadata for runtime reflection — More powerful than the old
Reflect.metadataapproach - Leverage
consttype parameters — They provide precise type inference withoutas constat call sites - Use multiple
extendsfor modular configuration — Share and compose configuration files - Enable
--verbatimModuleSyntax— Makes module imports more predictable - Test decorator behavior thoroughly — Decorators can have subtle ordering and interaction effects
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Mixing experimental and standard decorators | Confusing errors | Stick to one style per file/class |
| Using parameter decorators | Syntax error in standard | Refactor to method/property decorators |
Forgetting accessor keyword | Decorator doesn't work on properties | Use accessor for property decorators |
| Wrong context API | Runtime errors | Use new context.metadata instead of Reflect |
Missing const type parameter | Widened types | Add const when literal types matter |
| Config extends order | Wrong overrides | Order configs from least to most specific |
Comparison with Alternatives
| Feature | TS 5.0 Decorators | Experimental Decorators | Aspect-Oriented Libraries |
|---|---|---|---|
| TC39 standard | ✅ Stage 3 | ❌ Legacy | N/A |
| Parameter decorators | ❌ Not supported | ✅ Supported | Varies |
| Auto-accessors | ✅ New | ❌ Not available | N/A |
| Metadata API | Built-in (Symbol.metadata) | Reflect.metadata | Custom |
| Framework support | Growing (Angular 16+) | Established | N/A |
| TypeScript support | âś… Native | experimentalDecorators | External |
Testing Strategies
describe('Standard Decorators', () => {
test('method decorator modifies behavior', () => {
class TestClass {
@log
testMethod(x: number) {
return x * 2;
}
}
const instance = new TestClass();
const consoleSpy = jest.spyOn(console, 'log');
instance.testMethod(5);
expect(consoleSpy).toHaveBeenCalledWith('Calling testMethod with', [5]);
});
test('field decorator validates values', () => {
class TestClass {
@validate
accessor name: string = '';
}
const instance = new TestClass();
expect(() => { instance.name = ''; }).toThrow('name is required');
expect(() => { instance.name = 'John'; }).not.toThrow();
});
});
describe('Const Type Parameters', () => {
test('preserves literal types', () => {
function identity<const T>(value: T): T {
return value;
}
const result = identity({ x: 1, y: 2 });
// Type: { readonly x: 1; readonly y: 2 }
expect(result.x).toBe(1);
});
});
describe('Multiple Config Extends', () => {
test('configs compose correctly', () => {
// Verify configuration loading order
const config = loadConfig();
expect(config.target).toBe('ES2022');
expect(config.strict).toBe(true);
});
});Decorator Metadata and Introspection
The decorator metadata API enables decorators to attach metadata to classes and class members that other code can introspect at runtime. TypeScript 5.0 introduces the Symbol.metadata property on class prototypes, providing a standard location for decorator metadata storage. Frameworks use this metadata for dependency injection, validation schema generation, and serialization rules without requiring separate configuration files.
function validate(target: any, context: ClassFieldDecoratorContext) {
const metadata = target.constructor[Symbol.metadata] ??= {};
metadata[context.name] = { required: true, type: 'string' };
}
class User {
@validate
name: string;
}
// Read metadata elsewhere
const rules = User[Symbol.metadata];
// { name: { required: true, type: 'string' } }The metadata API works through a prototype chain, so subclass metadata inherits from parent class metadata while allowing overrides. This enables patterns where a base Model class defines common validation rules through decorators, and subclasses add or modify rules for specific fields. The metadata is available immediately after class definition, making it compatible with both runtime reflection and build-time code generation.
Using satisfies with Const Type Parameters
The combination of satisfies and const type parameters creates a powerful pattern for configuration objects that need both type safety and precise literal types. The satisfies operator validates that a value matches an expected type without widening, while const type parameters preserve literal types through generic functions. Together, they enable configuration objects to have strict types for IDE autocompletion while remaining assignable to their expected structural type.
function defineRoutes<const T extends Record<string, RouteConfig>>(routes: T) {
return routes;
}
const routes = defineRoutes({
home: { path: '/', method: 'GET' as const },
users: { path: '/users', method: 'POST' as const },
});
// TypeScript knows the exact literal types
type HomePath = typeof routes.home.path; // '/'
type UsersMethod = typeof routes.users.method; // 'POST'This pattern is particularly valuable for router definitions, form schemas, and API client configurations where the exact values matter for type-level computations. The const assertion ensures that string literals remain literal types rather than widening to string, enabling type-safe path parameter extraction and method validation.
Future Outlook
TypeScript 5.0's features are driving the ecosystem forward:
- Standard decorator adoption is accelerating — Angular 16+, NestJS 10+, and other frameworks are migrating
consttype parameters are widely used — Essential for type-level programming and library design- Multiple extends simplifies configuration — Monorepos and shared configs benefit greatly
--verbatimModuleSyntaxis becoming the default — More predictable module behavior- Decorator metadata enables new patterns — Runtime type information without external libraries
Conclusion
TypeScript 5.0 was a transformative release that brought decorators into the standard fold while adding powerful type-level features. The standard decorators, const type parameters, and multiple config extends collectively improve both the metaprogramming capabilities and the developer experience of TypeScript.
Key takeaways:
- Standard decorators — TC39 Stage 3 implementation with auto-accessors and metadata API
consttype parameters — Preserve literal types in generic inference- Multiple
extends— Compose configuration files for modular tsconfig setup - Migration path exists — Both experimental and standard decorators are supported
- Decorator metadata — Runtime type information via
Symbol.metadata
If you're starting a new TypeScript project, use standard decorators from the beginning. For existing projects, plan your migration strategy—the ecosystem is rapidly adopting the new standard, and the benefits in type safety and metaprogramming power are worth the transition.