TypeScript Decorators Finally Shipped: What Changed in 2026
- Stage 3 rewrote the decorator signature and killed reflect-metadata
- Migration is a tsconfig flip plus signature rewrites, not a full refactor
- Validation decorators replace 80 percent of class-validator overhead
- Instrumentation decorators give logging and timing for free
- ORMs are split, Drizzle ignores them, TypeORM still runs legacy
- Decorators win for cross-cutting concerns, codegen still wins for schemas
Decorators have been the longest running embarrassment in the TypeScript ecosystem. For almost a decade, every framework that touched them shipped with a tsconfig flag called `experimentalDecorators` and a runtime polyfill called `reflect-metadata`, and every senior developer on the team would write a Slack message that started with the words "we should probably rip this out before it bites us." NestJS, TypeORM, Angular, Inversify, every dependency injection container in the registry: all built on a syntax proposal that TC39 had explicitly walked away from in 2016. The community kept shipping. The proposal kept rotting. By 2022, decorators were the part of the language nobody wanted to touch and nobody could replace. Then Stage 3 landed in TypeScript 5.0 in March 2023, the legacy era was over on paper, and three years later the runtime fallout has finally settled. This post is what I learned migrating production code from the legacy flag to Stage 3, and the patterns that earn their keep in 2026.
What Stage 3 Actually Changed
The headline change is the function signature. Legacy decorators received `(target, propertyKey, descriptor)` and you mutated the descriptor in place, which is why every legacy decorator looked like a property-descriptor PR from 2014. Stage 3 decorators receive `(value, context)` and return a replacement. The context object carries everything you used to dig out of the prototype manually: the kind, the name, whether it is static, the access object, and the addInitializer hook. No more reflection. No more guessing whether you got the constructor or the instance.
function logged any>(
value: T,
context: ClassMethodDecoratorContext
): T {
const name = String(context.name);
return function (this: any, ...args: any[]) {
console.log(`[${name}] called with`, args);
const result = value.apply(this, args);
console.log(`[${name}] returned`, result);
return result;
} as T;
}
class Api {
@logged
fetch(url: string) {
return `GET ${url}`;
}
}
The second shift is accessor decorators. Stage 3 introduces a real `accessor` keyword that creates a get/set pair backed by a private field, and decorators on accessors receive a typed object with `get` and `set` functions. This is the part that quietly killed 90 percent of the cases where teams reached for `reflect-metadata` to remember which fields existed on a class. The third shift is `addInitializer`, which lets a decorator register a callback that fires when the class or instance is constructed, which is how dependency injection now works without any runtime metadata table. Stage 3 is not a polish pass. It is a different mental model from the legacy flag, and treating it like a rename is the fastest way to ship subtle bugs.
Migration: From Legacy to Stage 3
The migration is less painful than the years of warnings suggested, with one large caveat. The tsconfig flip is real: remove `experimentalDecorators` and `emitDecoratorMetadata`, and TypeScript 5.0+ defaults to Stage 3. If you set `"experimentalDecorators": false` explicitly the new behavior turns on. The `reflect-metadata` import goes away unless a downstream library still requires it.
What breaks: every decorator factory you wrote yourself. The signatures are different, the return types are different, and parameter decorators do not exist in Stage 3 at all (only class, method, getter, setter, accessor, and field). What does not break: the call sites. `@Logged` still reads as `@Logged` at the top of a method. So the migration becomes a search and replace job inside the decorator definitions, and a refactor for any code that relied on parameter decorators (mostly DI frameworks).
// LEGACY (TypeScript < 5.0, experimentalDecorators)
function Logged(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[${key}]`, args);
return original.apply(this, args);
};
return descriptor;
}
// STAGE 3 (TypeScript 5.0+, no flag needed)
function Logged any>(
value: T,
context: ClassMethodDecoratorContext
): T {
return function (this: any, ...args: any[]) {
console.log(`[${String(context.name)}]`, args);
return value.apply(this, args);
} as T;
}
The hard cases are NestJS and TypeORM, which still rely on the legacy emit and parameter decorators. As of 2026, NestJS 11 supports both modes but defaults to legacy, and TypeORM has not migrated. If your stack runs on either, you are stuck on the old flag for the framework files but you can use Stage 3 for your own decorators by isolating them in a separate package with its own tsconfig. This is uglier than it sounds and is exactly why I moved most of my new backends to a Hono backend stack where decorators are optional rather than load-bearing.
Pattern 1: Validation Decorators
The first pattern that pays off is field validation. The legacy approach used class-validator plus reflect-metadata, and the runtime cost was real because every decorator wrote into a global metadata table. Stage 3 lets validation decorators store their rules on the field via `addInitializer`, and the validator walks the instance directly. No global state, no metadata import.
type Validator = (value: unknown, field: string) => string | null;
const validators = new WeakMap
This compiles in a single file, has no runtime dependencies, and replaces 80 percent of what class-validator did with about 50 lines of code. Worth noting: Zod and Valibot solve the same problem from a different direction, and for pure data validation I still reach for them first. Decorators win when validation lives next to behavior, not next to a schema file.
Pattern 2: Instrumentation (Logging + Timing)
The second pattern is the cleanest fit for Stage 3 and the easiest sell. Method decorators wrap the function, run before and after, and the call site stays untouched. I use this on every class that talks to a network or a database, because the alternative is wrapping every method by hand or running an APM agent that costs more per month than the server.
export function Logged any>(
value: T,
context: ClassMethodDecoratorContext
): T {
const name = String(context.name);
return function (this: any, ...args: any[]) {
const id = Math.random().toString(36).slice(2, 8);
console.log(`[${name}:${id}] start`, args);
try {
const result = value.apply(this, args);
if (result instanceof Promise) {
return result
.then((r) => {
console.log(`[${name}:${id}] ok`);
return r;
})
.catch((e) => {
console.error(`[${name}:${id}] err`, e);
throw e;
});
}
console.log(`[${name}:${id}] ok`);
return result;
} catch (e) {
console.error(`[${name}:${id}] err`, e);
throw e;
}
} as T;
}
export function Timed any>(
value: T,
context: ClassMethodDecoratorContext
): T {
const name = String(context.name);
return function (this: any, ...args: any[]) {
const start = performance.now();
const done = () => {
const ms = (performance.now() - start).toFixed(1);
console.log(`[${name}] ${ms}ms`);
};
const result = value.apply(this, args);
if (result instanceof Promise) {
return result.finally(done);
}
done();
return result;
} as T;
}
class PaymentService {
@Logged @Timed
async charge(amountEur: number, customerId: string) {
const res = await fetch("/api/charge", {
method: "POST",
body: JSON.stringify({ amountEur, customerId }),
});
return res.json();
}
}
Stack two decorators and you get structured logs plus timing on every call, with zero changes to the body. In production I wire `Logged` to a real logger (pino, winston) and `Timed` to a metrics emitter, but the shape stays the same. The key win is that the wrapper is composable. Legacy decorators required descriptor mutation that broke when you stacked them in the wrong order. Stage 3 returns a function, so stacking is just function composition. The order in which I migrated my services from a hand-rolled middleware pattern to this shape is the same migration story I told for Bun replaced Node in my new projects, where the simpler primitive won because it composed.
Pattern 3: ORM-Style Metadata
The most contested pattern is ORM metadata, and the answer in 2026 is messier than the rest. TypeORM still ships against the legacy flag and uses parameter decorators that Stage 3 does not support. MikroORM 6 added Stage 3 mode but warns you that the metadata coverage is incomplete. Drizzle ORM never used decorators in the first place, and its schema files compile down to plain TypeScript objects, which is one of the 8 Drizzle patterns that made it the default for every backend I shipped this year.
If you want decorator-driven schemas in 2026, the realistic options are MikroORM 6 in Stage 3 mode, or a hand-rolled metadata layer using `addInitializer`. The hand-rolled version is short:
type ColumnMeta = { name: string; type: "text" | "int" | "boolean" };
const schemas = new WeakMap
That gives you a schema introspection layer in 30 lines without any runtime dependency. For migrations and query building I still trust codegen over decorators, because a generated schema file diffs cleanly in PRs and a decorator schema does not. But for serialization, form mapping, and validation, this pattern is the right size.
The honest take on ORMs: if you are starting a new backend in 2026, pick Drizzle and skip decorators for data entirely. If you inherited a TypeORM codebase, do not migrate it for the sake of Stage 3. The legacy flag is supported in TypeScript 5.x and there is no removal date on the roadmap. Migrate when the framework migrates, not before.
Bottom Line
Three years after Stage 3 shipped, decorators have a clear job description. They are the right tool for cross-cutting concerns that need to live next to the code they affect: validation, instrumentation, deprecation marking, feature flags, permission checks, retries, caching. They are the wrong tool for anything a build step can do better, which means database schemas (use Drizzle or codegen), API route definitions (use a framework that takes plain functions), and serialization shapes (use Zod). The legacy flag still works, will keep working for years, and there is no urgency to migrate any code that already runs in production.
What I changed in 2026: every new class I write that has more than one method gets `@Logged` and `@Timed`. Every form model gets validation decorators. Every deprecated public API gets a `@Deprecated` decorator that logs once per process and increments a metric. Everything else stays as plain TypeScript. The full picture of how this fits into the rest of my stack lives on the Lab overview page along with the other primitives I converged on this year.
The decorator era is no longer experimental. It is also no longer the answer to every question. Use it for the 20 percent where it earns its keep, and write a function for the other 80.
Back to all articles