What TypeScript 5 Changed: Standard Decorators, satisfies, const Type Parameters and Migrating from 4
Key takeaways
TypeScript 5.0 brought standard ECMAScript decorators, const type parameters, bundler module resolution and a smaller, faster compiler; satisfies arrived just before it in 4.9. This guide explains each feature, where it differs from what it replaces, and what actually breaks when you upgrade from 4.x.
What Changed in TypeScript 5
TypeScript 5.0 (released March 2023) is a large release, though not a breaking one in the sense of Python 3 — most 4.x code compiles unchanged. The headline changes:
- Standard Decorators — the TC39 decorators proposal, implemented alongside (not instead of) the older
experimentalDecorators consttype parameters — infer literal/tuple types instead of widened typesmoduleResolution: "bundler"— resolution rules that match Vite, esbuild, and webpack- A faster, smaller compiler — the codebase moved from namespaces to ES modules
The satisfies operator is often listed as a TypeScript 5 feature because it became popular around the same time, but it shipped in 4.9 (November 2022). It is covered here because it is part of the same “keep precise types” story, and because many teams adopted it during the 5.0 upgrade.
The 5.x line has continued with smaller additions — switch (true) narrowing (5.3), NoInfer (5.4), inferred type predicates (5.5), and more — so “TypeScript 5” in a job listing or a package.json can mean quite different feature sets. Check the minor version when a feature seems to be missing.
Standard Decorators
TypeScript 5 implements the TC39 decorators proposal. The API is different from experimentalDecorators — a decorator receives the decorated value and a context object, and returns a replacement, instead of mutating a property descriptor on the prototype.
Class Decorator
// Logs every time the class is instantiated
function logged<T extends new (...args: any[]) => any>(
value: T,
context: ClassDecoratorContext<T>,
) {
const className = String(context.name);
return class extends value {
constructor(...args: any[]) {
super(...args);
console.log(`[${className}] Instance created`);
}
};
}
@logged
class User {
constructor(public name: string) {}
}
const user = new User('Alice');
// Output: [User] Instance created
The generic constraint is needed because you can only extends something TypeScript knows is a constructor; typing value as Function fails with “Type ‘Function’ is not a constructor function type”. Returning a subclass is the standard way to wrap construction. The subclass replaces User everywhere the name is used afterwards, so new User(...) runs the logging constructor, and objects created that way are still instanceof the original class because the subclass extends it.
Method Decorator — Measure Execution Time
function measure(target: Function, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
return function (this: any, ...args: any[]) {
const start = performance.now();
const result = target.apply(this, args);
const end = performance.now();
console.log(`[${methodName}] ${(end - start).toFixed(2)}ms`);
return result;
};
}
class DataProcessor {
@measure
processLargeDataset(data: number[]): number {
return data.reduce((sum, n) => sum + n, 0);
}
}
const processor = new DataProcessor();
processor.processLargeDataset(Array.from({ length: 1_000_000 }, (_, i) => i));
// Output: [processLargeDataset] <elapsed>ms
The replacement function uses function, not an arrow function, so that this is the instance the method is called on, and forwards it with target.apply(this, args). Using an arrow function here is the most common bug in hand-written method decorators: this becomes whatever it was in the decorator’s scope, and the method fails with “Cannot read properties of undefined”.
This decorator measures only synchronous work. For an async method, target.apply returns a promise immediately and the log shows the time to start the operation; to measure the whole call, check whether the result is a promise and log in .finally(). The context object also offers context.addInitializer(fn), which runs code when each instance is constructed — the standard way to do things like auto-binding methods.
Real-World Example: Route Registration
A lightweight HTTP router using decorators — similar in spirit to how NestJS registers controllers:
// Simple route registry
const routes = new Map<string, Function>();
function Route(path: string) {
return function (target: Function, context: ClassMethodDecoratorContext) {
routes.set(path, target);
return target;
};
}
class ApiController {
@Route('/api/users')
getUsers() {
return [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
}
@Route('/api/posts')
getPosts() {
return [{ id: 1, title: 'Hello TypeScript 5' }];
}
}
// Route dispatch
function handleRequest(path: string) {
const handler = routes.get(path);
return handler ? handler() : { error: 'Not Found' };
}
console.log(handleRequest('/api/users'));
// [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
Method decorators run once, when the class definition is evaluated — not when an instance is created. That is why the routes are registered without ever calling new ApiController(). It is also the limit of this example: the registry stores the bare function, so handler() runs with no this. It works only because these methods do not touch instance state; a real router would create the controller instance (or use context.addInitializer to register a bound method per instance) before dispatching.
Migrating from experimentalDecorators
// TypeScript 4 (experimentalDecorators: true)
function OldDecorator(target: any, key: string, descriptor: PropertyDescriptor) {
// ...
return descriptor;
}
// TypeScript 5 (standard, no flag needed)
function NewDecorator(target: Function, context: ClassMethodDecoratorContext) {
// context.name = method name
// context.kind = 'method' | 'getter' | 'setter' | 'field' | 'accessor' | 'class'
// context.static = boolean
// context.private = boolean
return target;
}
The two systems are selected by one flag: with experimentalDecorators: true TypeScript compiles every @decorator with the legacy semantics, and without it, with the standard ones. A single project cannot mix them. The standard version deliberately omits two things the legacy one had: parameter decorators (constructor(@Inject() svc: Service)) and emitDecoratorMetadata, which emitted design-time type information that dependency-injection containers read through reflect-metadata. Frameworks built around those — NestJS, TypeORM, Angular, InversifyJS — therefore keep experimentalDecorators on, and removing the flag breaks them with errors about decorator signatures. Only switch if your own decorators are the only ones in use.
For a project with no legacy dependencies, the standard mode needs no flag at all:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
satisfies Operator (TypeScript 4.9)
The satisfies operator validates that a value conforms to a type while preserving the original inferred type. It solves a common problem with as and type annotations.
The Problem satisfies Solves
type Color = 'red' | 'green' | 'blue' | [number, number, number];
// Annotating with the type
const palette: Record<string, Color> = {
primary: 'red',
secondary: [0, 255, 0],
};
// ❌ The annotation replaces what TypeScript knew about each property
palette.primary.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'Color'
palette.secondary.map((c) => c); // Error: Property 'map' does not exist on type 'Color'
palette.tertiary; // No error at all — any string key is allowed
// With satisfies
const palette = {
primary: 'red',
secondary: [0, 255, 0],
} satisfies Record<string, Color>;
// ✅ Type check passes AND TypeScript keeps the precise types
palette.primary.toUpperCase(); // ✅ primary is inferred as 'red'
palette.secondary.map((c) => c); // ✅ secondary is [number, number, number]
palette.tertiary; // ❌ Error: Property 'tertiary' does not exist
The annotation makes the variable’s type exactly Record<string, Color>, so every property is the full union and every string key “exists”. satisfies checks the object against the same type — a typo such as primary: 'rde' is still an error — but the variable keeps its own inferred type, with known keys and narrowed values. The target type also acts as a contextual type during inference, which is why [0, 255, 0] becomes a tuple instead of number[], and 'red' stays a literal.
Real-World Example: Configuration Object
type Environment = 'development' | 'staging' | 'production';
interface AppConfig {
env: Environment;
apiUrl: string;
features: Record<string, boolean>;
timeout: number;
}
// satisfies validates the shape while keeping literal types
const config = {
env: 'production',
apiUrl: 'https://api.example.com',
features: {
darkMode: true,
analyticsV2: false,
betaSearch: false,
},
timeout: 30_000,
} satisfies AppConfig;
// ✅ TypeScript knows config.env is literally 'production', not just Environment
if (config.env === 'production') {
enableProductionMonitoring();
}
// ✅ The known feature keys autocomplete, and typos are errors
console.log(config.features.darkMode); // boolean
The practical win is in the features object: with an AppConfig annotation, config.features.darkMod (typo) would compile, because Record<string, boolean> accepts any key. With satisfies, the object keeps its three known keys. Missing required properties are still reported — satisfies is not a looser check, only a check that does not overwrite the inferred type. One consequence to be aware of: because env is the literal 'production', a later comparison like config.env === 'staging' is flagged as “This comparison appears to be unintentional”, which is correct but surprises people who expected Environment.
satisfies vs as vs Type Annotation
| Type Annotation | as | satisfies | |
|---|---|---|---|
| Type checking | ✅ Yes | ❌ No (overrides) | ✅ Yes |
| Preserves inferred type | ❌ Widens to annotation | ❌ Widens to cast | ✅ Yes |
| Autocomplete precision | Lower | Lower | Higher |
| Use when | You want to enforce the type | You know better than TS | You want validation + precision |
as is not strictly “no checking”: TypeScript rejects assertions between types that do not overlap at all ('abc' as number is an error). But {} as AppConfig compiles, even though every property is missing, which is exactly the kind of silent hole satisfies is designed to avoid. Annotations remain the right choice for function parameters and for variables that will be reassigned, where you want the declared type rather than the initial value’s type.
const Type Parameters
Without const, generic functions widen literal types to their base types. const tells TypeScript to infer as if the argument had been written with as const.
The Problem
// Without const
function makeTuple<T extends readonly unknown[]>(items: T) {
return items;
}
const arr = makeTuple([1, 2, 3]);
// Inferred type: number[] ← widened
// Desired type: readonly [1, 2, 3] ← literal tuple
The Solution
// TypeScript 5.0 — add const to the type parameter
function makeTuple<const T extends readonly unknown[]>(items: T) {
return items;
}
const arr = makeTuple([1, 2, 3]);
// Inferred type: readonly [1, 2, 3] ✅
Two details decide whether const has any effect. The parameter must be typed as T itself (or an object containing T), not T[]: with items: T[], TypeScript infers T as 1 | 2 | 3 and the result is still an array, (1 | 2 | 3)[]. And the constraint should be readonly unknown[] rather than unknown[]; const inference produces readonly tuples, and a mutable-array constraint makes TypeScript fall back to the widened type. const also only affects literals written at the call site — passing a variable declared earlier (makeTuple(myArray)) uses that variable’s already-widened type.
Before 5.0, the same result required callers to write makeTuple([1, 2, 3] as const). The const modifier moves that responsibility into the function signature, which is why it shows up mostly in library APIs (routers, schema builders, event maps) rather than application code.
Real-World Example: Type-Safe Route Config
function defineRoutes<const T extends Record<string, string>>(routes: T) {
return routes;
}
const routes = defineRoutes({
home: '/',
about: '/about',
contact: '/contact',
});
// TypeScript knows the exact route names
type RouteName = keyof typeof routes; // 'home' | 'about' | 'contact'
function navigate(route: RouteName) {
window.location.href = routes[route];
}
navigate('home'); // ✅
navigate('invalid'); // ❌ Type error at compile time
Here the keys would be preserved even without const (object keys are never widened); what const adds is that the values stay literal — typeof routes.about is '/about' rather than string — which matters if you later build types from the paths, such as extracting :id parameters with template literal types.
Performance Improvements
The 5.0 release notes reported build-time improvements in the range of roughly 10–25% on the TypeScript team’s benchmark projects and a substantially smaller npm package. The main cause was internal: the compiler’s source moved from TypeScript namespaces to ES modules, which let the build tools produce faster, more optimizable output, and several internal data structures were simplified.
What you see depends on the project. Type-heavy codebases (large unions, deep conditional types, big generated API clients) are dominated by type checking, which the restructuring helps less. To find out, compare tsc --extendedDiagnostics output before and after upgrading — it prints check time, bind time, and memory — and if a build is slow, tsc --generateTrace <dir> shows which files and types take the time.
New bundler Module Resolution
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler", // New in TS 5.0 — for Vite, webpack, esbuild
"allowImportingTsExtensions": true, // Import .ts files directly
"noEmit": true // Let your bundler handle output
}
}
bundler exists because neither older mode described how bundlers actually resolve imports. node10 (formerly node) ignores the exports field in package.json, so packages that only expose entry points through exports resolve to the wrong files or fail with “Cannot find module”. node16/nodenext is accurate for Node’s native ESM, but requires file extensions on relative imports (./utils.js), which bundler users do not write. bundler supports exports and conditions while allowing extensionless imports.
It is a setting for code that a bundler will process. For a library or a Node service compiled by tsc and run directly by Node, use nodenext, otherwise the emitted JavaScript can contain imports Node cannot resolve at runtime. allowImportingTsExtensions is only allowed with noEmit or emitDeclarationOnly, because tsc does not rewrite .ts extensions in output.
Other New Features
All Enums are Union Enums
enum Status {
Pending = 'pending',
Active = 'active',
Inactive = 'inactive',
}
// Template literal types turn a string enum into a union of its values
type StatusLiteral = `${Status}`; // 'pending' | 'active' | 'inactive'
function processStatus(status: `${Status}`) {
// Works with both the enum member and string literals
}
processStatus(Status.Pending); // ✅
processStatus('pending'); // ✅
The ${Status} trick has worked since TypeScript 4.1; what 5.0 changed is underneath it. Previously, an enum with any computed member (B = someFunction()) fell back to being a plain number-like type. In 5.0 every enum member gets its own literal type, so all enums behave as unions of their members, narrowing works consistently, and assigning an out-of-range literal to a numeric enum (let s: E = 42) is now an error where it used to be silently allowed. That last change is one of the few that can break an existing build during the upgrade.
export type *
// types.ts
export type User = { id: number; name: string };
export type Post = { id: number; title: string };
export type Comment = { id: number; body: string };
// index.ts — re-export only types (no values)
export type * from './types';
export type * guarantees the re-export is erased from the JavaScript output, which matters with isolatedModules or verbatimModuleSyntax (the 5.0 flag that replaces importsNotUsedAsValues and preserveValueImports). Under verbatimModuleSyntax, any import used only as a type must be written import type, because single-file transpilers like esbuild and SWC cannot tell whether an import is a type without the full program.
Improved switch(true) Narrowing (TypeScript 5.3)
function describe(value: string | number | boolean): string {
switch (true) {
case typeof value === 'string':
return value.toUpperCase(); // TypeScript 5.3+: value is narrowed to string ✅
case typeof value === 'number':
return value.toFixed(2); // narrowed to number ✅
default:
return String(value);
}
}
Before 5.3, each case expression was evaluated but not used for narrowing, so value.toUpperCase() was an error and people rewrote such code as if chains. On TypeScript 5.0–5.2 this example does not compile.
Migration Guide: TypeScript 4 → 5
# 1. Update TypeScript
npm install -D typescript@latest
# 2. Check for errors
npx tsc --noEmit
# 3. Fix any new errors, then build
npm run build
Key tsconfig.json changes:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"experimentalDecorators": false,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
}
}
Keep "experimentalDecorators": true instead if any dependency uses legacy decorators — see the decorators section above.
What to watch for:
- 5.0 deprecated
target: "ES3",importsNotUsedAsValues,preserveValueImports,out, and a few other options. They produce errors unless you set"ignoreDeprecations": "5.0", which buys time until they are removed; plan to replace them rather than suppress the warning indefinitely. - Running
tsc5.x requires a newer Node.js than 4.x did (5.0 needs Node 12.20+), which occasionally breaks old CI images. - The new
bundlerresolution mode may catch previously-ignored import issues, especially packages whoseexportsmap does not include types. - Type errors that appear only after the upgrade are usually genuine: improved inference and enum checks catch code that was always wrong. Resist fixing them with
as any; that removes the value of the upgrade. @types/*packages and libraries that ship.d.tsfiles may use syntax newer than an old compiler understands, which is why editors and CI should use the same TypeScript version — pin it indevDependenciesand point VS Code at the workspace version.
Which feature solves what
| Feature | Version | What it solves | When to use |
|---|---|---|---|
| Standard Decorators | 5.0 | Standardized decorators without a flag | Your own decorators, no DI metadata needed |
satisfies | 4.9 | Validation without losing precision | Config objects, option maps |
const type params | 5.0 | Literal/tuple inference in generics | Route config, event registries |
bundler resolution | 5.0 | Matches Vite/webpack import rules | Code processed by a bundler |
switch (true) narrowing | 5.3 | Narrowing in case expressions | Replacing long if chains |
Related Articles
Frequently Asked Questions (FAQ)
Q. Why do my existing decorators still compile after upgrading to TypeScript 5?
A. TypeScript 5 added support for the standard TC39 decorators, but as long as experimentalDecorators is enabled in tsconfig.json, the compiler keeps using the older legacy decorator behavior. The two models have different signatures, and the standard version does not support parameter decorators or emitDecoratorMetadata. Frameworks that depend on those, such as those using reflect-metadata, should stay on experimentalDecorators until they announce support for the new model.