TypeScript 5, and the decorators we had been faking

TypeScript 5.0 arrived in March with an implementation of the stage 3 decorators proposal. That is a different feature from the one behind experimentalDecorators, which we had been using since 2019 with two compiler flags nobody on the team could explain.

The symptom

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2018",
    "useDefineForClassFields": false
  }
}

// four settings, three of which exist to make decorators
// work, and the fourth to stop them breaking.
asked four people what emitDecoratorMetadata does:

  "it makes the types available at runtime"     partly
  "the DI container needs it"                   true, and
                                                nobody
                                                knew why
  "it's required for decorators"                no
  "I copied it from a tutorial"                 honest

Why it happens

The proposal changed shape three times over seven years, and the version TypeScript shipped early became load-bearing for an ecosystem before the standard settled. Everybody’s configuration is a fossil of whichever year they started.

The fix

What is actually different

// legacy: a property descriptor, and three overloads
function logged(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value
  descriptor.value = function (...args: any[]) {
    console.log(key)
    return original.apply(this, args)
  }
}

// standard: a value and a context object
function logged<This, Args extends any[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This>,
) {
  return function (this: This, ...args: Args): Return {
    console.log(String(context.name))
    return target.call(this, ...args)
  }
}

Returning a replacement rather than mutating a descriptor is the substantive change, and it is what makes the new form typeable — the signature above is fully generic over the decorated method, which the legacy version could not express.

The context object, and addInitializer

function bound<This, Args extends any[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This>,
) {
  context.addInitializer(function (this: This) {
    // runs once per instance, at construction
    ;(this as any)[context.name] = target.bind(this)
  })
}

// which replaces the legacy trick of returning a getter
// from the descriptor, and works with private members

The metadata that is gone

emitDecoratorMetadata wrote design:paramtypes into
Reflect metadata, which is how a DI container knows what
a constructor wants without being told.

the standard proposal has no equivalent. context.metadata
exists and holds what decorators put there — it does not
hold the type system's view of anything.

which means:
  @injectable() with implicit constructor types
    → needs explicit @inject(Token) per parameter
  a validation library reading property types
    → needs the type declared in the decorator

This is the actual migration cost and it falls on whichever library was reading the metadata. Ours was a small internal container, and adding explicit tokens to eleven constructors was an hour — a project on a large framework that depends on implicit constructor injection is looking at a much longer wait.

Four decorators, migrated

  @logged        mechanical. 20 minutes.
  @measured      mechanical, plus the context name for
                 the metric label.
  @retry         rewritten. it had mutated the descriptor
                 and captured `this` incorrectly in a way
                 the legacy form permitted.
  @injectable    deleted. replaced with an explicit
                 factory function, because the metadata
                 it needed no longer exists.

three migrated, one removed, and the removed one was the
reason the flags were there.

The @retry case is worth flagging: it had a bug the legacy form hid, because mutating the descriptor let it capture the wrong this and still work for the one call pattern it was used with. The new signature made the error a compile failure.

const type parameters, from the same release

// before: every call site writes as const
function defineRoutes<T extends readonly string[]>(paths: T): T
defineRoutes(['/a', '/b'] as const)

// 5.0
function defineRoutes<const T extends readonly string[]>(paths: T): T
defineRoutes(['/a', '/b'])     // readonly ['/a', '/b']

Verifying it worked

$ grep -c 'experimentalDecorators|emitDecoratorMetadata' tsconfig.json
0

$ npx tsc --noEmit
# no errors

$ npm run build && ls -la dist/index.js
-rw-r--r--  1  184320   # was 201728
# 17 KB smaller: reflect-metadata is gone

$ npm test
  88 passing
# including the @retry test that had been asserting
# the buggy behaviour, now rewritten

Dropping reflect-metadata from the bundle is the measurable outcome and it was not the goal — it is a consequence of the metadata feature no longer existing, which means the polyfill was only ever there to support it.

What this costs

A library pinned until it supports the new form, if you have one. We did not, because the only consumer of the metadata was our own code — a project using a framework built on the legacy decorators cannot make this move at all until that framework does, and the two cannot be mixed within a compilation.

The decorators are also more verbose to write. The generic signature above is six lines of types for a function that logs a method name, which is the cost of a decorator that is actually type-safe. For a codebase with two decorators, plain higher-order functions remain the simpler answer.