Saturday, 4 October 2025

TypeScript decorators: not actually decorators

G'day:

I've been working through TypeScript classes, and when I got to decorators I hit the @ syntax and thought "hang on, what the heck is all this doing inside the class being decorated? The class shouldn't know it's being decorated. Fundamentally it shouldn't know."

Turns out TypeScript decorators have bugger all to do with the Gang of Four decorator pattern. They're not about wrapping objects at runtime to extend behavior. They're metaprogramming annotations - more like Java's @annotations or C#'s [attributes] - that modify class declarations at design time using the @ syntax.

The terminology collision is unfortunate. Python had the same debate back in PEP 318 - people pointed out that "decorator" was already taken by a well-known design pattern, but they went with it anyway because the syntax visually "decorates" the function definition. TypeScript followed Python's lead: borrowed the @ syntax, borrowed the confusing name, and now we're stuck with it.

So this isn't about the decorator pattern at all. This is about TypeScript's metaprogramming features that happen to be called decorators for historical reasons that made sense to someone, somewhere.

What TypeScript deco

What TypeScript decorators actually do

A decorator in TypeScript is a function that takes a target (the thing being decorated - a class, method, property, whatever) and a context object, and optionally returns a replacement. They execute at class definition time, not at runtime.

The simplest example is a getter decorator:

function obscurer(
  originalMethod: (this: PassPhrase) => string,
  context: ClassGetterDecoratorContext
) {
  void context
  function replacementMethod(this: PassPhrase) {
    const duplicateOfThis: PassPhrase = Object.assign(
      Object.create(Object.getPrototypeOf(this) as PassPhrase),
      this,
      { _text: this._text.replace(/./g, '*') }
    ) as PassPhrase

    return originalMethod.call(duplicateOfThis)
  }

  return replacementMethod
}

export class PassPhrase {
  constructor(protected _text: string) {}

  get plainText(): string {
    return this._text
  }

  @obscurer
  get obscuredText(): string {
    return this._text
  }
}

(from accessor.ts)

The decorator function receives the original getter and returns a replacement that creates a modified copy of this, replaces the _text property with asterisks, then calls the original getter with that modified context. The original instance is untouched - we're not mutating state, we're intercepting the call and providing different data to work with. The @obscurer syntax applies the decorator to the getter.

The test shows this in action:

it('original text remains unchanged', () => {
  const phrase = new PassPhrase('tough_to_guess')
  expect(phrase.obscuredText).toBe('**************')
  expect(phrase.plainText).toBe('tough_to_guess')
})

(from accessor.test.ts)

The obscuredText getter returns asterisks, the plainText getter returns the original value. The decorator wraps one getter without affecting the other or mutating the underlying _text property.

Method decorators and decorator factories

Method decorators work the same way as getter decorators, except they handle methods with actual parameters. More interesting is the decorator factory pattern - a function that returns a decorator, allowing runtime configuration.

Here's an authentication service with logging:

interface Logger {
  log(message: string): void
}

const defaultLogger: Logger = console

export class AuthenticationService {
  constructor(private directoryServiceAdapter: DirectoryServiceAdapter) {}

  @logAuth()
  authenticate(userName: string, password: string): boolean {
    const result: boolean = this.directoryServiceAdapter.authenticate(
      userName,
      password
    )
    if (!result) {
      throw new AuthenticationException(
        `Authentication failed for user ${userName}`
      )
    }
    return result
  }
}

function logAuth(logger: Logger = defaultLogger) {
  return function (
    originalMethod: (
      this: AuthenticationService,
      userName: string,
      password: string
    ) => boolean,
    context: ClassMethodDecoratorContext<
      AuthenticationService,
      (userName: string, password: string) => boolean
    >
  ) {
    void context
    function replacementMethod(
      this: AuthenticationService,
      userName: string,
      password: string
    ) {
      logger.log(`Authenticating user ${userName}`)
      try {
        const result = originalMethod.call(this, userName, password)
        logger.log(`User ${userName} authenticated successfully`)
        return result
      } catch (e) {
        logger.log(`Authentication failed for user ${userName}: ${e}`)
        throw e
      }
    }
    return replacementMethod
  }
}

(from method.ts)

The factory function takes a logger parameter and returns the actual decorator function. The decorator wraps the method with logging: logs before calling, logs on success, logs on failure and re-throws. The @logAuth() syntax calls the factory which returns the decorator.

Worth noting: the logger has to be configured at module level because @logAuth() executes when the class is defined, not when instances are created. This means tests can't easily inject different loggers per instance - you're stuck with whatever was configured when the file loaded. It's a limitation of how decorators work, and honestly it's a bit crap for dependency injection.

Also note I'm just using the console as the logger here. It makes testing easy.

Class decorators and shared state

Class decorators can replace the entire class, including hijacking the constructor. This example is thoroughly contrived but demonstrates how decorators can inject stateful behavior that persists across all instances:

const maoriNumbers = ['tahi', 'rua', 'toru', 'wha']
let current = 0
function* generator() {
  while (current < maoriNumbers.length) {
    yield maoriNumbers[current++]
  }
  throw new Error('No more Maori numbers')
}

function maoriSequence(
  target: typeof Number,
  context: ClassDecoratorContext
) {
  void context

  return class extends target {
    _value = generator().next().value as string
  }
}

type NullableString = string | null

@maoriSequence
export class Number {
  constructor(protected _value: NullableString = null) {}

  get value(): NullableString {
    return this._value
  }
}

(from class.ts)

The class decorator returns a new class that extends the original, overriding the _value property with the next value from a generator. The generator and its state live at module scope, so they're shared across all instances of the class. Each time you create a new instance, the constructor parameter gets completely ignored and the decorator forces the next Maori number instead:

it('intercepts the constructor', () => {
  expect(new Number().value).toEqual('tahi')
  expect(new Number().value).toEqual('rua')
  expect(new Number().value).toEqual('toru')
  expect(new Number().value).toEqual('wha')
  expect(() => new Number()).toThrowError('No more Maori numbers')
})

(from class.test.ts)

First instance gets 'tahi', second gets 'rua', third gets 'toru', fourth gets 'wha', and the fifth throws an error because the generator is exhausted. The state persists across all instantiations because it's in the decorator's closure at module level.

This demonstrates that class decorators can completely hijack construction and maintain shared state, which is both powerful and horrifying. You'd never actually do this in real code - it's terrible for testing, debugging, and reasoning about behavior - but it shows the level of control decorators have over class behavior.

GitHub Copilot's code review was appropriately horrified by this. It flagged the module-level state, the generator that never resets, the constructor hijacking, and basically everything else about this approach. Fair cop - the code reviewer was absolutely right to be suspicious. This is demonstration code showing what's possible with decorators, not what you should actually do. In real code, if you find yourself maintaining stateful generators at module scope that exhaust after four calls and hijack constructors to ignore their parameters, you've gone badly wrong somewhere and need to step back and reconsider your life choices.

Auto-accessors and the accessor keyword

Auto-accessors are a newer feature that provides shorthand for creating getter/setter pairs with a private backing field. The accessor keyword does automatically what you'd normally write manually:

export class Person {
  @logCalls(defaultLogger)
  accessor firstName: string

  @logCalls(defaultLogger)
  accessor lastName: string

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName
    this.lastName = lastName
  }

  getFullName(): string {
    return `${this.firstName} ${this.lastName}`
  }
}

(from autoAccessors.ts)

The accessor keyword creates a private backing field plus public getter and setter, similar to C# auto-properties. The decorator can then wrap both operations:

function logCalls(logger: Logger = defaultLogger) {
  return function (
    target: ClassAccessorDecoratorTarget,
    context: ClassAccessorDecoratorContext
  ) {
    const result: ClassAccessorDecoratorResult = {
      get(this: This) {
        logger.log(`[${String(context.name)}] getter called`)
        return target.get.call(this)
      },
      set(this: This, value) {
        logger.log(
          `[${String(context.name)}] setter called with value [${String(value)}]`
        )
        target.set.call(this, value)
      }
    }

    return result
  }
}

(from autoAccessors.ts)

The target provides access to the original get and set methods, and the decorator returns a result object with replacement implementations. The getter wraps the original with logging before calling it, and the setter does the same.

Testing shows both operations getting logged:

it('should log the setters being called', () => {
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
  new Person('Jed', 'Dough')

  expect(consoleSpy).toHaveBeenCalledWith(
    '[firstName] setter called with value [Jed]'
  )
  expect(consoleSpy).toHaveBeenCalledWith(
    '[lastName] setter called with value [Dough]'
  )
})

it('should log the getters being called', () => {
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
  const person = new Person('Jed', 'Dough')

  expect(person.getFullName()).toBe('Jed Dough')
  expect(consoleSpy).toHaveBeenCalledWith('[firstName] getter called')
  expect(consoleSpy).toHaveBeenCalledWith('[lastName] getter called')
})

(from autoAccessors.test.ts)

The constructor assignments trigger the setters, which get logged. Later when getFullName() accesses the properties, the getters are logged.

Auto-accessors are actually quite practical compared to the other decorator types. They provide a clean way to add cross-cutting concerns like logging, validation, or change tracking to properties without cluttering the class with boilerplate getter/setter implementations.

What I learned

TypeScript decorators are metaprogramming tools that modify class behavior at design time. They're useful for cross-cutting concerns like logging, validation, or instrumentation - the kinds of things that would otherwise clutter your actual business logic.

The main decorator types are:

  • Getter/setter decorators - wrap property access
  • Method decorators - wrap method calls
  • Class decorators - replace or modify entire classes
  • Auto-accessor decorators - wrap the getter/setter pairs created by the accessor keyword

Decorator factories (functions that return decorators) allow runtime configuration, though "runtime" here means "when the module loads", not "when instances are created". This makes dependency injection awkward - you're stuck with module-level state or global configuration.

The syntax is straightforward once you understand the pattern: decorator receives target and context, returns replacement (or modifies via context), job done. The tricky bit is the type signatures and making sure your implementation signature is flexible enough to handle all the overloads you're declaring.

But fundamentally, these aren't decorators in the design pattern sense. They're annotations that modify declarations. If you're coming from a language with proper decorators (the GoF pattern), you'll need to context-switch your brain because the @ syntax is doing something completely different here.

Worth learning? Yeah, if only because you'll see them in the wild and need to understand what they're doing.

Would I use them in my own code? Probably sparingly. Auto-accessors are legitimately useful. Method decorators for logging or metrics could work if you're comfortable with the module-level configuration limitations. Class decorators that hijack constructors and maintain shared state can absolutely get in the sea.

But to be frank: if I wanted to decorate something - in the accurate sense of that term - I'd do it properly using the design pattern, and DI.


The full code for this investigation is in my learning-typescript repository.

Righto.

--
Adam