Hero image for TypeScript 5.x Decorators and Const Type Parameters: A Migration Guide for Production Codebases

TypeScript 5.x Decorators and Const Type Parameters: A Migration Guide for Production Codebases


Your team just upgraded to TypeScript 5.x and suddenly half your decorator-based dependency injection breaks. The @Injectable() decorators that powered your entire service layer now throw cryptic errors. Your custom @Authorize middleware decorators compile but fail silently at runtime. The legacy experimental decorators your codebase relied on for years—the ones every NestJS tutorial taught you to use, the ones that made InversifyJS tick—now conflict with the new ECMAScript-aligned implementation.

The instinct is to roll back. Add "experimentalDecorators": true back to your tsconfig, close the upgrade PR, and revisit this in six months when “the ecosystem catches up.” But here’s the problem: the ecosystem already caught up. Angular 16+, NestJS 10+, and TypeORM 0.3+ all ship with TypeScript 5.x decorator support. The frameworks moved. Your codebase didn’t.

This isn’t a minor syntax change. TypeScript 5.x decorators represent a fundamental architectural shift from the experimental implementation that’s powered enterprise TypeScript applications since 2015. The execution order changed. The metadata reflection mechanism that reflect-metadata provided—the entire foundation of runtime dependency injection—doesn’t exist in the new spec. Class member decorators now receive a context object instead of property descriptors. Every assumption baked into your existing decorator patterns needs reevaluation.

The good news: you don’t need to rewrite everything at once. A surgical, incremental migration is possible—one that keeps your application running in production while you systematically update decorator patterns module by module. But first, you need to understand exactly what changed and why.

The Decorator Dilemma: Why TypeScript 5.x Changed Everything

TypeScript decorators have powered enterprise frameworks for nearly a decade. NestJS controllers, TypeORM entities, Angular components—all built on a feature that never left experimental status. TypeScript 5.0 changed that by implementing ECMAScript Stage 3 decorators, and in doing so, broke assumptions baked into millions of lines of production code.

Visual: TypeScript decorator evolution from experimental to ECMAScript Stage 3

Two Incompatible Standards

The experimental decorators you’ve been using since TypeScript 1.5 follow a design proposal that the TC39 committee ultimately rejected. These decorators receive three arguments: the target object, the property key, and a property descriptor. They execute at class definition time and can freely modify the target.

ECMAScript Stage 3 decorators take a fundamentally different approach. They receive a decorator context object containing metadata about what’s being decorated, and they return a replacement value or initializer function. The execution model changed too—decorators now run in a specific order with defined semantics for class element initialization.

This isn’t a minor API adjustment. The two systems have incompatible function signatures, different execution timing, and conflicting assumptions about what decorators can access and modify. Code written for one system won’t compile under the other.

The Metadata Reflection Problem

Experimental decorators gained their power through reflect-metadata, a polyfill that enabled runtime type reflection. When you write a NestJS injectable service, the framework uses emitDecoratorMetadata to capture constructor parameter types at compile time. At runtime, Reflect.getMetadata('design:paramtypes', target) retrieves those types for dependency injection.

ECMAScript decorators deliberately exclude this capability. The Stage 3 proposal focuses on decoration patterns that don’t require compiler-emitted metadata. Type information exists only at compile time—exactly where JavaScript’s type erasure model expects it to stay.

Frameworks worked around this limitation for years by combining two TypeScript compiler flags: experimentalDecorators and emitDecoratorMetadata. The compiler would emit metadata as part of the JavaScript output, and reflect-metadata would make it accessible at runtime. This worked, but it created a dependency on non-standard compiler behavior that the new decorator implementation doesn’t support.

💡 Pro Tip: The emitDecoratorMetadata flag only works with experimentalDecorators. If you’re using standard decorators, you need an alternative metadata strategy—typically explicit decorator arguments or a separate metadata registration system.

Why Frameworks Haven’t Fully Migrated

NestJS, TypeORM, and Angular all continue supporting experimental decorators because the migration path requires more than syntax changes. These frameworks need to either abandon automatic type-based injection or implement new metadata collection mechanisms. Both options require significant breaking changes to their public APIs.

Understanding this architectural divide is essential before attempting any migration. The next section covers how to run both decorator systems simultaneously during your transition period.

Running Both Decorator Systems: The Transition Architecture

Migrating a production codebase from legacy decorators to TypeScript 5.x native decorators isn’t a weekend project. For applications built on NestJS, TypeORM, or Angular, decorators are woven throughout the entire architecture. The practical solution is running both systems simultaneously while you migrate incrementally.

Strategic Use of experimentalDecorators

The experimentalDecorators flag determines which decorator specification TypeScript uses. When enabled, TypeScript compiles decorators according to the legacy stage 2 proposal. When disabled, it uses the ECMAScript-compliant stage 3 specification that shipped in TypeScript 5.0.

The key insight: this flag applies per-compilation, not per-file. You cannot mix decorator systems within a single tsconfig.json scope. This constraint shapes your entire migration architecture.

tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true
}
}
tsconfig.legacy.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist/legacy",
"rootDir": "./src/legacy"
},
"include": ["src/legacy/**/*"]
}
tsconfig.modern.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"experimentalDecorators": false,
"outDir": "./dist/modern",
"rootDir": "./src/modern"
},
"include": ["src/modern/**/*"]
}

Isolating Legacy and Modern Code

Physical separation of decorator systems prevents accidental mixing and makes the migration scope visible to your team. Structure your source directory to reflect this boundary:

src/
├── legacy/ # experimentalDecorators: true
│ ├── controllers/
│ ├── services/
│ └── entities/
├── modern/ # experimentalDecorators: false
│ ├── decorators/
│ └── utilities/
└── shared/ # No decorators - pure TypeScript
├── types/
└── constants/

The shared/ directory contains code that both systems import. Keep this decorator-free to avoid compilation conflicts. Types, interfaces, constants, and pure functions belong here. Any decorated class that needs to be accessed from both sides should remain in legacy/ until fully migrated—attempting to create adapter layers between decorator systems introduces unnecessary complexity.

Pro Tip: Create a lint rule that prevents importing from legacy/ into modern/. This enforces a one-way migration path and prevents new code from depending on legacy patterns.

Build Pipeline Configuration

Your build process needs to compile both configurations and merge the outputs. Modify your build script to handle parallel compilation:

package.json
{
"scripts": {
"build:legacy": "tsc --project tsconfig.legacy.json",
"build:modern": "tsc --project tsconfig.modern.json",
"build": "npm run build:legacy && npm run build:modern",
"build:parallel": "concurrently \"npm:build:legacy\" \"npm:build:modern\""
}
}

For monorepos using tools like Nx or Turborepo, treat legacy and modern code as separate library targets with explicit dependency relationships. This approach integrates cleanly with incremental builds and caching. Define clear boundaries between packages: legacy packages can depend on shared packages, modern packages can depend on shared packages, but modern packages should never import from legacy packages directly.

Framework-Specific Considerations

NestJS, TypeORM, and Angular all depend on emitDecoratorMetadata for runtime reflection. This flag only works with experimentalDecorators: true. Your legacy code must retain both flags until these frameworks release TypeScript 5.x-compatible versions.

src/legacy/services/user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
}

This service continues working unchanged under tsconfig.legacy.json. Meanwhile, new utility decorators you write for logging, validation, or caching can use the modern specification under tsconfig.modern.json.

Watch for framework version updates carefully. As major frameworks adopt native decorators, you can migrate entire subsystems rather than individual files. Track the GitHub issues and roadmaps for your dependencies—NestJS and TypeORM both have active discussions about TypeScript 5.x decorator support.

IDE Configuration

VS Code uses a single tsconfig.json for language services. Create a root configuration that references both project configurations:

tsconfig.json
{
"files": [],
"references": [
{ "path": "./tsconfig.legacy.json" },
{ "path": "./tsconfig.modern.json" }
]
}

This enables proper IntelliSense, error highlighting, and go-to-definition across both decorator systems. The files: [] entry prevents the root config from compiling anything directly—it exists solely to coordinate the project references for tooling purposes.

If you encounter inconsistent behavior, verify that each source file falls under exactly one tsconfig.json scope. Overlapping include patterns cause unpredictable compilation results and confusing IDE errors that are difficult to diagnose.

With this architecture in place, you can migrate at your own pace. Legacy framework code stays stable while new code adopts native decorators. The next step is understanding exactly how to rewrite those decorators—the syntax differences are subtle but significant.

Rewriting Decorators for TypeScript 5.x: Before and After

The new decorator specification fundamentally changes how you write and consume decorators. Rather than receiving a target and property key as separate arguments, decorators now receive the decorated value directly along with a context object that provides metadata and initialization hooks. This shift eliminates the need for the experimental reflect-metadata library in many scenarios while providing stronger type safety guarantees. This section walks through concrete refactoring patterns for each decorator type, highlighting the conceptual differences and practical migration strategies.

Class Decorators: Embracing the Context Object

Legacy class decorators received the constructor function directly. TypeScript 5.x decorators receive both the constructor and a ClassDecoratorContext object that provides type-safe access to the class name, kind, and metadata. This context object is the cornerstone of the new decorator model—it standardizes how decorators communicate with each other and with the runtime.

Legacy pattern:

decorators/injectable.legacy.ts
function Injectable(options?: { scope: 'singleton' | 'transient' }) {
return function (target: Function) {
Reflect.defineMetadata('injectable:scope', options?.scope ?? 'singleton', target);
return target;
};
}
@Injectable({ scope: 'singleton' })
class UserService {}

TypeScript 5.x pattern:

decorators/injectable.ts
function Injectable(options?: { scope: 'singleton' | 'transient' }) {
return function <T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext<T>
) {
context.metadata['injectable:scope'] = options?.scope ?? 'singleton';
context.metadata['injectable:name'] = context.name;
return target;
};
}
@Injectable({ scope: 'singleton' })
class UserService {}
// Access metadata via Symbol.metadata
const meta = UserService[Symbol.metadata];
console.log(meta?.['injectable:scope']); // 'singleton'

The context.metadata object is shared across all decorators applied to a class and its members, giving you a centralized location for decorator data without relying on reflect-metadata. This shared metadata object enables sophisticated decorator composition patterns where multiple decorators can read and write to a common data structure, coordinating their behavior without explicit coupling.

💡 Pro Tip: The metadata object is prototype-inherited. If you’re decorating a subclass, check for existing keys using Object.hasOwn(context.metadata, key) to avoid reading parent class metadata unintentionally. This inheritance behavior is intentional—it allows subclasses to inherit decorator metadata from parent classes while still being able to override specific values.

Method and Accessor Decorators: The addInitializer Pattern

Method decorators no longer receive a property descriptor. Instead, they receive the method itself and use context.addInitializer() to run setup logic when the class is instantiated. This represents a fundamental architectural shift: rather than mutating descriptors during class definition, you now schedule initialization callbacks that execute at instance creation time.

Legacy pattern:

decorators/log-method.legacy.ts
function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey} with:`, args);
return original.apply(this, args);
};
return descriptor;
}

TypeScript 5.x pattern:

decorators/log-method.ts
function LogMethod<T extends (...args: any[]) => any>(
target: T,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
function replacementMethod(this: any, ...args: Parameters<T>): ReturnType<T> {
console.log(`Calling ${methodName} with:`, args);
return target.apply(this, args);
}
return replacementMethod as T;
}
class OrderService {
@LogMethod
processOrder(orderId: string, quantity: number) {
return { orderId, quantity, status: 'processed' };
}
}

For decorators that need access to the instance (common in DI frameworks), use addInitializer. The initializer function runs with this bound to the newly created instance, giving you a reliable hook for instance-specific setup that was previously achieved through descriptor manipulation or constructor patching:

decorators/bind-method.ts
function Bind<T extends (...args: any[]) => any>(
target: T,
context: ClassMethodDecoratorContext
) {
context.addInitializer(function (this: any) {
this[context.name] = target.bind(this);
});
return target;
}

The addInitializer pattern proves particularly valuable for decorators that register event handlers, set up subscriptions, or perform other side effects that require access to the fully constructed instance. Initializers run in decorator application order, providing predictable sequencing for complex decorator stacks.

Property Decorators: Life Without Runtime Reflection

Property decorators present the biggest migration challenge. Legacy decorators relied on emitDecoratorMetadata to access type information at runtime. TypeScript 5.x decorators receive undefined as the target value for properties, and type metadata is not automatically emitted. This deliberate design decision pushes developers toward explicit, runtime-safe patterns rather than relying on compile-time type information that could become stale or incorrect.

Legacy pattern:

decorators/inject.legacy.ts
function Inject(target: any, propertyKey: string) {
const type = Reflect.getMetadata('design:type', target, propertyKey);
// Use type to resolve dependency from container
}

TypeScript 5.x pattern:

decorators/inject.ts
function Inject(token: symbol | string) {
return function (
_target: undefined,
context: ClassFieldDecoratorContext
) {
context.metadata[`inject:${String(context.name)}`] = token;
return function (this: any, initialValue: unknown) {
// Return resolved dependency or initial value
return Container.resolve(token) ?? initialValue;
};
};
}
const USER_REPO = Symbol('UserRepository');
class UserController {
@Inject(USER_REPO)
private userRepository!: UserRepository;
}

The key shift: you must explicitly pass injection tokens rather than relying on type reflection. This is more verbose but eliminates the fragile dependency on TypeScript’s metadata emission. The explicit token approach also improves minification compatibility and makes your dependency graph statically analyzable by build tools.

💡 Pro Tip: For large codebases, create a code generator that reads your existing @Inject() usages and outputs the explicit token versions. A simple AST transform with ts-morph handles most cases in under 100 lines. Consider running this as a one-time migration script, then updating your linting rules to enforce explicit tokens going forward.

The accessor decorator variant (context.kind === 'accessor') provides getter/setter pairs that you return as an object, enabling reactive property patterns without manual descriptor manipulation. This is particularly useful for observable properties, validation logic, or lazy initialization patterns where you need fine-grained control over property access.

With your decorators rewritten for the new specification, you can now leverage another TypeScript 5.x feature that pairs exceptionally well with decorator-heavy codebases: const type parameters for stricter inference in your factory functions and builders.

Const Type Parameters: Eliminating as const Throughout Your Codebase

If you’ve worked with TypeScript generics, you’ve written code like this more times than you’d like to admit:

routes.ts
const routes = defineRoutes([
{ path: '/users', method: 'GET' },
{ path: '/users/:id', method: 'POST' },
] as const);

That trailing as const assertion forces TypeScript to infer literal types instead of widening 'GET' to string. It works, but it’s a constant source of bugs when developers forget to add it—and the resulting type errors rarely point to the missing assertion. Instead, you get cryptic messages about incompatible types deep in your codebase, far from the actual problem.

TypeScript 5.0 introduced const type parameters, allowing generic functions to request literal type inference directly. This shifts the responsibility from every call site to the function definition itself, eliminating an entire category of developer error.

The Mechanics of Const Type Parameters

Adding const before a type parameter tells TypeScript to infer the narrowest possible type, as if the caller had written as const:

defineRoutes.ts
function defineRoutes<const T extends readonly { path: string; method: string }[]>(
routes: T
): T {
return routes;
}
// Inferred type: readonly [{ path: "/users"; method: "GET" }, { path: "/users/:id"; method: "POST" }]
const routes = defineRoutes([
{ path: '/users', method: 'GET' },
{ path: '/users/:id', method: 'POST' },
]);

The const modifier applies recursively. Object properties become readonly, arrays become tuples, and string values become literal types—all without any annotation at the call site. This recursive behavior is what makes const type parameters particularly powerful: nested objects several levels deep all receive the same literal type treatment automatically.

Practical Applications

Configuration Objects

Framework configuration benefits immediately. Consider a feature flag system:

featureFlags.ts
function createFeatureFlags<const T extends Record<string, boolean>>(flags: T): T {
return flags;
}
const flags = createFeatureFlags({
darkMode: true,
betaFeatures: false,
experimentalApi: true,
});
// Type-safe access: flags.darkMode is `true`, not `boolean`
if (flags.darkMode) {
// TypeScript knows this branch always executes
}

This pattern enables exhaustiveness checking and control flow narrowing that would be impossible with widened boolean types. The compiler can prove certain branches are unreachable, catching dead code during development rather than in production.

Action Creators

Redux-style action creators become significantly cleaner:

actions.ts
function createAction<const T extends string, const P>(type: T, payload: P) {
return { type, payload } as const;
}
const addUser = createAction('users/add', { name: 'Alice', role: 'admin' });
// Type: { type: "users/add"; payload: { name: "Alice"; role: "admin" } }

Notice that both type parameters use the const modifier. This ensures the action type string remains literal for discriminated union matching, while the payload preserves its exact shape for downstream type guards.

API Route Definitions

Type-safe API clients can extract path parameters automatically:

apiClient.ts
function endpoint<const P extends `/${string}`>(path: P) {
return { path };
}
const userEndpoint = endpoint('/api/users/:userId/posts/:postId');
// P is literally "/api/users/:userId/posts/:postId"
// Template literal types can now extract :userId and :postId

Combined with template literal type manipulation, this enables fully type-safe route parameter extraction. Libraries like tRPC and Hono leverage this pattern extensively to provide compile-time guarantees for API contracts.

When to Exercise Restraint

Const type parameters aren’t universally beneficial. The compiler performs more work inferring deeply nested literal types, and the resulting types appear in error messages and IDE tooltips. For large configuration objects with hundreds of properties, this adds measurable compilation overhead and reduces readability. Hover tooltips that span dozens of lines obscure rather than illuminate.

Avoid const modifiers when:

  • The function internally widens types anyway (storing values in a string[] array)
  • Callers genuinely need mutable data structures
  • The literal types provide no downstream benefit
  • The inferred types become unwieldy for debugging and code review

💡 Pro Tip: Introduce const type parameters at API boundaries—functions consumed by other teams or modules. Internal utilities often don’t benefit from the additional type precision.

The migration path is straightforward: identify generic functions where callers consistently add as const, add the const modifier to the type parameter, and remove the assertions from call sites. Your IDE’s find-all-references makes this a mechanical refactor. Consider enabling the noPropertyAccessFromIndexSignature compiler option to surface additional locations where literal types would improve safety.

Beyond const type parameters, TypeScript 5.x includes several inference improvements that compound these benefits—starting with smarter narrowing in control flow analysis.

Type Inference Improvements You’re Probably Not Using Yet

TypeScript 5.x shipped with inference improvements that fly under the radar because they don’t require any code changes. Your existing code simply gets smarter type checking for free. These aren’t flashy syntax additions or new keywords—they’re quiet upgrades to how the compiler reasons about your types. Here’s what you’re missing.

Better Return Type Inference for Union-Returning Functions

Before TypeScript 5.x, functions returning multiple types often collapsed into overly broad unions. The compiler now tracks control flow more precisely through return statements, understanding that each branch produces a distinct shape.

utils/response-handler.ts
function handleApiResponse(status: number) {
if (status === 200) {
return { success: true, data: fetchUserData() };
}
if (status === 404) {
return { success: false, error: "Not found" };
}
return { success: false, error: "Unknown error", retryable: true };
}
// TypeScript 5.x infers:
// { success: true; data: User } | { success: false; error: string } | { success: false; error: string; retryable: boolean }
const result = handleApiResponse(200);
if (result.success) {
// TypeScript knows result.data exists here
console.log(result.data.email);
}

Previously, you’d annotate this return type explicitly or watch the compiler merge properties incorrectly, often flattening your carefully constructed union into a single object type with optional properties everywhere. Now the discriminated union emerges naturally from your control flow, and downstream code benefits from proper narrowing without any additional work on your part.

Improved Narrowing in Indexed Access Types

TypeScript 5.x narrows indexed access types more aggressively when you check properties on generic objects. This improvement particularly benefits utility functions that traverse configuration objects or work with strongly-typed dictionaries.

lib/config-parser.ts
type ConfigMap = {
database: { host: string; port: number };
cache: { ttl: number; maxSize: number };
logging: { level: "debug" | "info" | "error" };
};
function getConfigValue<K extends keyof ConfigMap>(
config: ConfigMap,
key: K,
subKey: keyof ConfigMap[K]
): ConfigMap[K][typeof subKey] {
return config[key][subKey];
}
// The return type now correctly resolves based on both key and subKey
const port = getConfigValue(config, "database", "port"); // number
const level = getConfigValue(config, "logging", "level"); // "debug" | "info" | "error"

The compiler tracks the relationship between K and subKey through the indexed access, preserving the specific type instead of widening to string | number. In earlier versions, you’d often see the return type collapse to a union of all possible values across all config sections, forcing you to add type assertions at call sites or write more verbose overload signatures.

Conditional Type Simplification

TypeScript 5.x evaluates conditional types earlier in the inference process, reducing situations where you see unresolved T extends U ? X : Y in error messages. This makes generic utilities significantly more usable in practice.

types/api-response.ts
type ExtractPayload<T> = T extends { payload: infer P } ? P : never;
type WebhookEvent = {
type: "user.created";
payload: { userId: string; email: string };
};
// Before: sometimes showed ExtractPayload<WebhookEvent>
// Now: immediately resolves to { userId: string; email: string }
type UserPayload = ExtractPayload<WebhookEvent>;

This matters most in complex generic utilities where chained conditional types previously produced unreadable hover information and confusing error messages. When you’re debugging type errors three levels deep in a utility chain, seeing the actual resolved type instead of Awaited<ReturnType<typeof handler>> makes the difference between a five-minute fix and an hour of frustration.

💡 Pro Tip: Run tsc --noEmit on your codebase after upgrading. You’ll likely see several “implicitly has type ‘any’” errors disappear as the improved inference catches cases that previously fell through. This is also a good opportunity to audit your explicit annotations—many may now be redundant.

These inference wins compound across your codebase. Every explicit annotation you remove is one less thing to maintain when your types evolve. Code that previously required manual type assertions or verbose generics often works with simpler signatures now. But while type inference improves automatically, module resolution still needs explicit configuration—which brings us to the bundlerModuleResolution setting and why it finally aligns TypeScript with how modern bundlers actually resolve imports.

Module Resolution and bundlerModuleResolution: Fixing the Import Headaches

TypeScript’s module resolution has historically been a source of friction, particularly as the JavaScript ecosystem has fragmented between CommonJS and ES modules. TypeScript 5.x introduces bundler mode to address the reality that most production applications don’t run directly in Node.js—they pass through a bundler first. Understanding when to use each resolution strategy eliminates hours of debugging cryptic import errors.

Visual: Module resolution strategies comparison across Node.js and bundler environments

Understanding node16 and nodenext

The node16 and nodenext resolution modes exist to mirror Node.js’s native ESM implementation. When Node.js added ES module support, it imposed strict requirements: relative imports must include file extensions, and package.json exports fields take precedence over traditional main field resolution. These modes ensure TypeScript’s compile-time checking matches Node.js’s runtime behavior exactly.

tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
}
}

With this configuration, TypeScript enforces Node.js semantics:

src/services/user.service.ts
// This fails with nodenext - missing extension
import { validateEmail } from './validators';
// This works - explicit extension required
import { validateEmail } from './validators.js';

Note the .js extension even when importing TypeScript files. This matches Node.js runtime behavior where the compiled JavaScript files are what actually get resolved. The distinction between node16 and nodenext is subtle: node16 locks to Node.js 16 semantics, while nodenext tracks the latest Node.js module behavior and will evolve with future Node.js releases.

When bundler Mode Is the Right Choice

For applications using Vite, webpack, esbuild, or similar tools, the bundler resolution mode eliminates unnecessary friction. Bundlers don’t enforce Node.js’s strict ESM rules—they resolve bare specifiers, handle extension-less imports, and manage CJS/ESM interop automatically. This mode reflects how modern frontend toolchains actually resolve modules.

tsconfig.json
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true
}
}

The bundler mode allows extension-less imports while still respecting package.json exports fields. This configuration works seamlessly with Vite’s development server and build process. The key insight is that bundler mode trusts your build tool to handle resolution details that TypeScript would otherwise enforce strictly.

💡 Pro Tip: When using bundler mode with a framework like NestJS that compiles TypeScript directly, you’ll still need nodenext for the server-side code. Consider separate tsconfig.json files for different parts of your monorepo.

Resolving ESM/CJS Interop Errors

The most common migration headache involves importing CommonJS modules from ES module contexts. TypeScript 5.x provides verbatimModuleSyntax to make import/export behavior explicit and predictable:

tsconfig.json
{
"compilerOptions": {
"verbatimModuleSyntax": true
}
}

This flag replaces the older esModuleInterop and isolatedModules settings with clearer semantics. Imports are emitted exactly as written, forcing you to use the correct syntax for each module type:

src/config/database.ts
// For CJS modules with default exports
import createConnection = require('typeorm');
// For true ESM modules
import { DataSource } from 'typeorm';
// Type-only imports are stripped entirely
import type { ConnectionOptions } from 'typeorm';

When encountering TS1479 errors about CJS modules being imported as ESM, the fix depends on your target environment:

src/utils/logger.ts
// Error: Module 'winston' is a CJS module
import winston from 'winston';
// Solution 1: Use namespace import
import * as winston from 'winston';
// Solution 2: Use dynamic import for true ESM compatibility
const winston = await import('winston');

For library authors publishing dual CJS/ESM packages, TypeScript 5.x’s resolution modes correctly handle conditional exports. The exports field in your package.json must specify separate entry points for each module format:

package.json
{
"name": "@acme/shared-utils",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
}
}
}

TypeScript resolves the appropriate entry point based on the consuming project’s module resolution settings, ensuring type definitions align with the actual JavaScript that will execute at runtime.

The combination of correct module resolution settings and verbatimModuleSyntax eliminates the guesswork from TypeScript’s module handling, making build errors predictable and fixable. When you encounter resolution errors, the path forward becomes clear: match your resolution mode to your runtime environment, use bundler for bundled applications, and let verbatimModuleSyntax enforce explicit import semantics throughout your codebase.

With module resolution configured correctly, the final step is establishing a systematic rollout process. The next section provides a concrete checklist for coordinating TypeScript 5.x adoption across engineering teams.

Migration Checklist: Rolling Out TypeScript 5.x Across Teams

Large-scale TypeScript migrations fail when teams treat them as single-commit changes. A staged rollout with clear checkpoints prevents production incidents and gives teams time to adapt their workflows.

Pre-Migration Audit

Before touching any configuration, catalog your decorator usage across the codebase. Run a grep for @ symbols in your TypeScript files and classify them by framework: NestJS dependency injection, TypeORM entities, class-validator decorators, or custom implementations. Each category carries different migration risk.

Custom decorators require the most attention. Review every decorator factory in your codebase and document whether it relies on emitDecoratorMetadata for reflection. These decorators need rewrites to accept explicit type parameters under the new system.

Audit your build tooling as well. Babel configurations, webpack loaders, and Jest transformers all interact with TypeScript’s decorator output. Map these dependencies before changing compiler options.

💡 Pro Tip: Generate a dependency graph of your decorator usage. Decorators that wrap other decorators create migration ordering constraints that aren’t obvious from file-level analysis.

Staged Rollout Strategy

Start with development environments running TypeScript 5.x while production builds remain on 4.x. This dual-version period exposes compatibility issues without production risk. Engineers encounter migration friction in their daily work, generating actionable feedback before the stakes increase.

Move to staging environments once your test suite passes consistently on 5.x. Run your staging environment for at least two sprint cycles to catch edge cases that unit tests miss. Monitor for runtime errors that indicate decorator metadata issues—these surface as dependency injection failures or ORM mapping errors.

Production migration happens last. Schedule it during a low-traffic window and keep rollback procedures ready. The TypeScript compiler version rarely causes runtime issues if staging validation was thorough, but framework interactions remain unpredictable.

Automated Tooling

Manual migration doesn’t scale beyond a few hundred files. Leverage jscodeshift codemods to transform legacy decorator syntax automatically. The TypeScript team maintains migration scripts for common patterns, and framework maintainers publish their own.

Set up CI checks that enforce the new decorator syntax on modified files. This prevents regression while allowing unmigrated code to coexist temporarily. Combine these checks with a tracking dashboard that shows migration progress per team or module.

Allocate explicit sprint capacity for migration work. Teams that treat migration as “spare time” work never finish. Assign ownership, set deadlines by module, and review progress weekly.

With your migration complete, the ongoing maintenance burden decreases significantly—but only if your module resolution configuration matches your bundler’s expectations.

Key Takeaways

  • Enable experimentalDecorators temporarily during migration but create a timeline to remove it—the new decorator system is the future
  • Use const type parameters on generic functions that accept configuration objects or literal arrays to eliminate manual as const assertions
  • Set moduleResolution to bundler if you’re using Vite, esbuild, or similar tools—it eliminates most ESM/CJS import resolution errors
  • Audit your codebase for reflect-metadata usage before upgrading, as this is the most common source of decorator migration failures