MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

TypeScript 5.0: Decorators, const Type Parameters, and More

Explore TypeScript 5.0: standard decorators, const type parameters, multiple config extends, and more.

TypeScriptDecoratorsJavaScript

By MinhVo

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.

TypeScript 5.0 release

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:

  1. Parameter decorators removed: Standard decorators don't support parameter decorators
  2. Different context object: The context API is completely different
  3. Auto-accessors: New accessor keyword for property decorators
  4. 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 5

Auto-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 undefined

Decorator 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);
  }
}

Decorator patterns

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 string

Using 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 preserved

Practical 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 --preserveValueImports

enum 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();
  }
}

Modern TypeScript

Migration from Experimental to Standard Decorators

Migration Strategy

  1. Keep experimental decorators during transition — Both can coexist
  2. Update decorator implementations one at a time — Don't try to migrate everything at once
  3. Remove parameter decorators — Standard decorators don't support them
  4. 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 types

Best Practices for Production

  1. Start with standard decorators for new projects — They're the future of decorators in JavaScript
  2. Keep experimental decorators for existing Angular/NestJS projects — The ecosystem is still migrating
  3. Use decorator metadata for runtime reflection — More powerful than the old Reflect.metadata approach
  4. Leverage const type parameters — They provide precise type inference without as const at call sites
  5. Use multiple extends for modular configuration — Share and compose configuration files
  6. Enable --verbatimModuleSyntax — Makes module imports more predictable
  7. Test decorator behavior thoroughly — Decorators can have subtle ordering and interaction effects

Common Pitfalls and Solutions

PitfallImpactSolution
Mixing experimental and standard decoratorsConfusing errorsStick to one style per file/class
Using parameter decoratorsSyntax error in standardRefactor to method/property decorators
Forgetting accessor keywordDecorator doesn't work on propertiesUse accessor for property decorators
Wrong context APIRuntime errorsUse new context.metadata instead of Reflect
Missing const type parameterWidened typesAdd const when literal types matter
Config extends orderWrong overridesOrder configs from least to most specific

Comparison with Alternatives

FeatureTS 5.0 DecoratorsExperimental DecoratorsAspect-Oriented Libraries
TC39 standard✅ Stage 3❌ LegacyN/A
Parameter decorators❌ Not supported✅ SupportedVaries
Auto-accessors✅ New❌ Not availableN/A
Metadata APIBuilt-in (Symbol.metadata)Reflect.metadataCustom
Framework supportGrowing (Angular 16+)EstablishedN/A
TypeScript supportâś… NativeexperimentalDecoratorsExternal

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:

  1. Standard decorator adoption is accelerating — Angular 16+, NestJS 10+, and other frameworks are migrating
  2. const type parameters are widely used — Essential for type-level programming and library design
  3. Multiple extends simplifies configuration — Monorepos and shared configs benefit greatly
  4. --verbatimModuleSyntax is becoming the default — More predictable module behavior
  5. 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:

  1. Standard decorators — TC39 Stage 3 implementation with auto-accessors and metadata API
  2. const type parameters — Preserve literal types in generic inference
  3. Multiple extends — Compose configuration files for modular tsconfig setup
  4. Migration path exists — Both experimental and standard decorators are supported
  5. 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.