CodeFixesHub
    programming tutorial

    Typing Decorator Pattern Implementations (vs ES Decorators)

    Learn to implement and type decorator-like patterns without ES decorators. Practical TS patterns, examples, and tips — follow the step-by-step guide now.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 11
    Published
    21
    Min Read
    2K
    Words
    article summary

    Learn to implement and type decorator-like patterns without ES decorators. Practical TS patterns, examples, and tips — follow the step-by-step guide now.

    Typing Decorator Pattern Implementations (vs ES Decorators)

    Introduction

    Decorators are a popular way to extend and modify behavior in object oriented code. In TypeScript many developers reach for the ES decorator syntax, but that approach has limitations: experimental status, unstable metadata behavior, and awkward interactions with type inference and accessory constructs. This tutorial focuses on implementing decorator patterns in TypeScript without relying on the ES decorator syntax. Instead of language-level decorators we build typed, composable, and safe decorator-like utilities using functions, higher order factories, proxies, and class wrappers.

    In this guide you will learn how to design decorator patterns that preserve type information, maintain correct this typing, and interoperate with existing TypeScript patterns like getters and setters, private members, and higher-order functions. We cover concrete, practical examples: method logging, caching and memoization, access control wrappers, validation decorators implemented as assertion functions, and composing multiple behaviors safely. Each pattern is accompanied by typed code examples and guidance for preserving argument and return types using variadic tuples and utility types.

    By the end you will be comfortable replacing ES decorators with explicit, typed patterns that are easier to test, debug, and evolve in a TypeScript codebase. You will also see when a real ES decorator may still make sense and how to migrate both directions safely.

    Background & Context

    Historically decorators allow attaching behavior to classes, methods, properties, and parameters. The ES decorator proposal has been through multiple iterations, and TypeScript provides experimental support behind a compiler flag. However, relying on that feature can introduce runtime and typing surprises. The alternative is to implement the decorator pattern manually: produce factories that wrap functions or classes, apply proxies, or return decorated class factories. These patterns are plain JavaScript but can be strongly typed in TypeScript.

    Why does this matter for intermediate developers? Explicitly implemented decorators avoid experimental features, give clearer runtime semantics, and often produce simpler, more predictable type relationships. They play well with other typing techniques such as typed memoization, typed higher order functions, and precise variance control. Throughout this article we emphasize typed patterns that interoperate with common TypeScript utilities like ThisParameterType and variadic tuples for arguments.

    Key Takeaways

    • Learn how to implement decorator-like behaviors without ES decorators
    • Preserve argument and return types using generics and variadic tuples
    • Use ThisParameterType and OmitThisParameter to keep this-safety
    • Compose wrappers to add logging, caching, validation, and throttling
    • Apply type-safe class factories and proxies for method-level decoration
    • Avoid common pitfalls with private members and getters/setters

    Prerequisites & Setup

    You should know TypeScript basics, generics, and utility types such as ReturnType and Parameters. Familiarity with variadic tuples is helpful but not required. Use TypeScript 4.0+ for variadic tuple features and 3.7+ for assertion functions. Install a standard TypeScript toolchain and a node environment to run examples.

    Example setup commands:

    • npm init -y
    • npm install typescript --save-dev
    • npx tsc --init

    Use tsconfig options strict: true for best practice. Some examples rely on newer TS features. If you want to experiment with experimental decorators later, refer to migration paths, but this guide keeps to stable runtime behavior.

    Main Tutorial Sections

    1. Method Wrapper Basics: Typed Higher-Order Functions

    Start by implementing a function wrapper that logs calls while preserving parameter and return types. Use variadic tuples to capture argument types and a generic R for return type.

    ts
    function logCall<Args extends unknown[], R>(fn: (...args: Args) => R, name = 'fn') {
      return (...args: Args): R => {
        console.log(`[${name}] called with`, args)
        const result = fn(...args)
        console.log(`[${name}] returned`, result)
        return result
      }
    }
    
    // usage
    const sum = (a: number, b: number) => a + b
    const loggedSum = logCall(sum, 'sum')
    loggedSum(1, 2)

    The wrapper keeps precise types for both arguments and result. If you need to preserve this typing for methods, see the section on preserving this.

    This pattern is a form of a higher-order function; explore advanced HOF typing approaches in our guide on Typing Higher-Order Functions.

    2. Preserving this: ThisParameterType and OmitThisParameter

    When wrapping methods that rely on this, you must keep correct this typing. TypeScript helpers ThisParameterType and OmitThisParameter help.

    ts
    type Method<T extends (this: any, ...args: any[]) => any> = T
    
    function wrapMethod<T extends (this: any, ...args: any[]) => any>(
      fn: T,
      name?: string
    ): OmitThisParameter<T> {
      return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
        console.log(name ?? 'method', 'args', args)
        return fn.apply(this, args as any)
      } as OmitThisParameter<T>
    }
    
    class Counter {
      count = 0
      inc(amount = 1) {
        this.count += amount
      }
    }
    
    Counter.prototype.inc = wrapMethod(Counter.prototype.inc, 'inc')

    Preserving this is crucial for class-bound logic, particularly when dealing with private and protected members. For tips on typing private members and access control in classes see Typing Private and Protected Class Members.

    3. Decorating Class Methods via Class Factories

    You can apply method-level decorations by producing a class factory that returns a subclass with wrapped methods. This keeps the original class untouched and is friendly to composition.

    ts
    function withLogging<T extends new (...args: any[]) => any>(Ctor: T) {
      return class extends Ctor {
        constructor(...a: any[]) {
          super(...a)
          const proto = Object.getPrototypeOf(this)
          for (const key of Object.getOwnPropertyNames(proto)) {
            const desc = Object.getOwnPropertyDescriptor(proto, key)
            if (desc?.value && typeof desc.value === 'function' && key !== 'constructor') {
              // replace with wrapped method
              Object.defineProperty(this, key, {
                value: function (this: any, ...args: any[]) {
                  console.log(`[${String(key)}]`, args)
                  return (desc.value as Function).apply(this, args)
                }
              })
            }
          }
        }
      }
    }
    
    class Service {
      ping(n: number) { return n }
    }
    const LoggedService = withLogging(Service)
    new LoggedService().ping(5)

    This factory preserves constructor typing through generics. When you need more type-friendly factories, consult patterns from the factory typing guide: Typing Factory Pattern Implementations in TypeScript.

    4. Typed Memoization and Caching Decorators

    Memoization is a classic use case for decorators. Implementing it without ES decorators keeps full control of key generation and typing.

    ts
    function memoize<Args extends unknown[], R>(fn: (...args: Args) => R) {
      const cache = new Map<string, R>()
      return (...args: Args) => {
        const key = JSON.stringify(args)
        if (cache.has(key)) return cache.get(key) as R
        const result = fn(...args)
        cache.set(key, result)
        return result
      }
    }
    
    const fib = memoize((n: number): number => n < 2 ? n : fib(n - 1) + fib(n - 2))

    For patterns and edge cases in typed caching, including TTLs and eviction strategies, see our practical guide on Typing Cache Mechanisms and the memoization guide Typing Memoization Functions in TypeScript.

    5. Validation and Assertion Wrappers

    Decorator-like validation can be implemented with assertion functions. Use them to fail early and refine types at runtime.

    ts
    function assertIsNumber(x: unknown): asserts x is number {
      if (typeof x !== 'number') throw new TypeError('Expected number')
    }
    
    function withValidation<T extends (arg: any) => any>(fn: T) {
      return (arg: Parameters<T>[0]) => {
        assertIsNumber(arg)
        return fn(arg)
      }
    }
    
    const square = withValidation((n: number) => n * n)

    Using assertion functions lets callers benefit from narrowed types. Learn more about writing assertion functions in Using Assertion Functions in TypeScript (TS 3.7+).

    6. Throttling / Debouncing as Decorators

    Throttling and debouncing are easy to implement as wrappers around functions. The challenge is typing the return and preserving context for methods.

    ts
    function debounce<Args extends any[], R>(fn: (...args: Args) => R, wait = 100) {
      let timer: ReturnType<typeof setTimeout> | null = null
      return (...args: Args) => {
        if (timer) clearTimeout(timer)
        timer = setTimeout(() => fn(...args), wait)
      }
    }
    
    const debouncedLog = debounce((msg: string) => console.log(msg), 200)

    For more on typed debouncing and throttling utilities, see Typing Debounce and Throttling Functions in TypeScript.

    7. Property Accessors and Getters/Setters

    Decorating getters and setters is trickier because you need to preserve descriptor semantics. Using property descriptors or class factories can help. When wrapping getter logic that relies on type inference, ensure your wrappers maintain getter types.

    ts
    function withGetterCache<T, K extends keyof T>(proto: T, key: K) {
      const desc = Object.getOwnPropertyDescriptor(proto as any, key)!
      if (!desc.get) return
      const originalGet = desc.get
      Object.defineProperty(proto as any, key, {
        get: function () {
          const cacheKey = `__cache_${String(key)}`
          if (this.hasOwnProperty(cacheKey)) return (this as any)[cacheKey]
          const val = originalGet!.call(this)
          Object.defineProperty(this, cacheKey, { value: val, writable: false })
          return val
        }
      })
    }
    
    class Box {
      get value() { return Math.random() }
    }
    withGetterCache(Box.prototype, 'value')

    For more on typing getters and setters and the tradeoffs, consult Typing Getters and Setters in Classes and Objects.

    8. Composing Multiple Decorators Safely

    Composition allows applying multiple wrappers while preserving typing. Compose HOFs carefully to keep argument and return types consistent.

    ts
    function compose<F extends Function>(...fns: F[]) {
      return fns.reduce((a, b) => (...args: any[]) => a(b(...args)))
    }
    
    const loggedMemoized = (fn: any) => memoize(logCall(fn, 'comp'))

    Prefer small focused wrappers and compose them explicitly rather than trying to infer complicated nested generics. For advanced composition patterns and variadic tuple handling, review Typing Functions That Accept a Variable Number of Arguments (Tuples and Rest).

    9. Using Proxies for Cross-Cutting Concerns

    If you need runtime interception of many operations across an object, a Proxy can act as a decorator for the whole object. Typed proxies require wrapper interfaces.

    ts
    function proxify<T extends object>(obj: T, handler: ProxyHandler<T>): T {
      return new Proxy(obj, handler)
    }
    
    const api = {
      async fetchUser(id: number) { return { id } }
    }
    
    const loggedApi = proxify(api, {
      get(target, p, receiver) {
        const v = Reflect.get(target, p, receiver)
        if (typeof v === 'function') {
          return function (this: any, ...args: any[]) {
            console.log('call', String(p), args)
            return (v as any).apply(this, args)
          }
        }
        return v
      }
    })

    Proxies are powerful but can obscure types; prefer explicit typed wrappers when possible. For async iteration patterns and typed streams, see Typing Async Iterators and Async Iterables in TypeScript — Practical Guide.

    Advanced Techniques

    Once you have the basic patterns, level up with these techniques. Use conditional types to infer decorated return types and mapped types to decorate entire interfaces. Create decorator factories that accept options and return strongly typed wrappers. For example, a cache factory with typed TTL and key strategy:

    ts
    function createMemoize<Args extends unknown[], R>(opts?: { key?: (...args: Args) => string }) {
      return (fn: (...args: Args) => R) => {
        const cache = new Map<string, R>()
        return (...args: Args) => {
          const key = opts?.key ? opts.key(...args) : JSON.stringify(args)
          if (cache.has(key)) return cache.get(key) as R
          const r = fn(...args)
          cache.set(key, r)
          return r
        }
      }
    }

    Use custom key generators to avoid pitfalls with complex arguments. When building multi-behavior decorators, build small building blocks and compose them with typed compose helpers. Consider codegen for massive cross-cutting concerns that must remain typed.

    Also explore mixing functional decorators with limited metadata stores instead of Reflect metadata. This reduces reliance on the metadata proposal and keeps behavior explicit.

    Best Practices & Common Pitfalls

    Dos:

    • Prefer explicit wrapper functions or class factories over experimental ES decorators for long-lived code
    • Preserve this with ThisParameterType and OmitThisParameter
    • Use variadic tuples to preserve argument shape
    • Keep wrappers small and composable
    • Add explicit runtime guards and assertion functions to validate inputs

    Dont's:

    • Avoid mutating private fields from wrappers; that breaks encapsulation and typings
    • Do not rely on JSON.stringify for cache keys when arguments include functions or circular structures
    • Avoid deep generic inference chains that become brittle and slow down compile times

    Troubleshooting tips:

    • If TypeScript loses inference, add explicit generic parameters at the use site
    • For decorated class factories, copy static members intentionally if you need them
    • Use runtime tests to ensure decorated methods still behave as expected

    For more on common class member typing issues, including private member handling, see Typing Private and Protected Class Members.

    Real-World Applications

    Typed decorator patterns are useful in many domains:

    • Logging and telemetry wrappers for services and repositories
    • Caching/memoization for CPU heavy calculations and API responses
    • Access control where wrapping methods enforces authorization checks
    • Input validation and assertion that refine types early
    • Debounce/throttle wrappers in UI code or event handlers

    When building large systems, use typed wrapper libraries to centralize cross-cutting concerns and keep core business logic pure and testable. Also integrate typed memoization and cache patterns from Typing Memoization Functions in TypeScript for performance-sensitive code.

    Conclusion & Next Steps

    Replacing ES decorators with typed, explicit decorator patterns yields clearer runtime semantics and often better type safety. Start by implementing small wrappers for logging, memoization, and validation, then compose them into class factories or proxies as necessary. Practice preserving this and argument types with utility types and variadic tuples. Next, try converting a few existing ES decorator usages into these patterns and measure maintainability and testability improvements.

    Recommended next reads from this series include typing higher order functions and cache strategies to broaden your toolbox.

    Enhanced FAQ

    Q1: Why avoid ES decorators in TypeScript?

    A: ES decorators are still evolving and TypeScript support has historically been experimental. Using explicit decorator implementations avoids runtime surprises, odd metadata behaviors, and makes control flow and typing explicit. Explicit wrappers are easier to test and debug because they are plain functions and factories.

    Q2: How do I preserve method this typing when wrapping instance methods?

    A: Use ThisParameterType to extract the original this type and OmitThisParameter to produce a typed wrapper without the explicit this parameter. You can reassign the wrapped method onto the prototype or instance using these helpers so TypeScript continues to understand the this shape.

    Q3: My wrapper loses argument types when composing multiple wrappers. How do I preserve them?

    A: Use variadic tuple generics: function wrappers typed as <Args extends unknown[], R>(fn: (...args: Args) => R) => (...args: Args) => R. For composition, ensure each wrapper preserves the same Args and R shapes or provide explicit adaptors when signatures differ. If inference breaks, add explicit type parameters at call sites.

    Q4: How can I memoize methods that are instance bound and reference this?

    A: For instance methods, create per-instance caches. You can initialize a WeakMap keyed by instance to store method caches or attach a non-enumerable cache property on the instance. Avoid storing caches on prototypes because those would be shared across instances.

    Q5: Is using Proxy a good replacement for decorators?

    A: Proxies are powerful for intercepting many operations at once, but they can obscure typing because Proxy returns an object typed as the original, but intercepted behaviors might not match. For most uses prefer explicit wrappers. Use Proxy for dynamic cross-cutting concerns where typed interfaces can be preserved or where dynamicism is required.

    Q6: How do I handle private members when decorating classes?

    A: Private fields are truly private in modern TS/JS if using #private syntax. Wrappers cannot access these. For decorator-like behavior that must interact with private state, consider exposing protected or internal methods explicitly or use class factories that subclass and add behavior within the class boundary. See best practices in Typing Private and Protected Class Members.

    Q7: How do I validate arguments in wrappers and get type narrowing for callers?

    A: Use assertion functions, typed as asserts param is Type. Those functions refine types for downstream code. You can expose a wrapper that runs assertions first and returns a typed result. More on writing assertion functions in Using Assertion Functions in TypeScript (TS 3.7+).

    Q8: Can I combine debounce/throttle with memoization or caching?

    A: Yes, but be careful: debounce delays execution and therefore delays cache population. Compose thoughtfully: if you need debounced invocation and memoization, memoize the underlying function while debouncing the public caller, or vice versa depending on semantics. See debounce and throttling patterns in Typing Debounce and Throttling Functions in TypeScript.

    Q9: What about decorating async methods and preserving promise types?

    A: Use the same generic approach: <Args extends unknown[], R>(fn: (...args: Args) => Promise) => (...args: Args) => Promise. For more advanced async stream decoration, refer to Typing Async Iterators and Async Iterables in TypeScript — Practical Guide.

    Q10: Where should I look next to broaden my decorator-like pattern knowledge?

    A: Read about higher order functions, memoization, and caches in this content set. These articles are particularly relevant: Typing Higher-Order Functions, Typing Memoization Functions in TypeScript, and Typing Cache Mechanisms. They will help you combine typed patterns into robust, production-ready utilities.

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:19:59 PM
    Next sync: 60s
    Loading CodeFixesHub...