Posts

Aspect-Oriented Programming in TypeScript: Where It Fits and Where It Doesn't

Some concerns are cross-cutting by nature. Logging, authorization, transaction boundaries, retries, caching, and tracing can apply across dozens of otherwise unrelated operations. Copying each concern into every method …

July 20, 2026 10 min read 2018 words

In this article

Some concerns are cross-cutting by nature.

Logging, authorization, transaction boundaries, retries, caching, and tracing can apply across dozens of otherwise unrelated operations. Copying each concern into every method creates repetition. Hiding them behind enough machinery can make the application feel haunted.

That tension is where Aspect-Oriented Programming, or AOP, enters the conversation. But TypeScript is not AspectJ, and putting an @ above a method does not automatically turn the code into AOP.

The practical question is not, “Can I make this cross-cutting?” It is, “Which boundary can own this behavior without hiding more than it helps?”

Consider operation timing. The behavior can wrap one call, decorate selected methods, or sit in the request pipeline. Each move reduces repetition while putting more distance between the selection rule and the affected code.

The choice comes down to four questions:

  1. What selects the affected code?
  2. When and where is the extra behavior applied?
  3. Which parts of the original contract must be preserved?
  4. How will a failure in the cross-cutting behavior affect the operation?

Build a Working AOP Model

Object-oriented code usually groups behavior around domain concepts: orders, invoices, users, repositories, and services. A cross-cutting concern follows a different axis.

                    OrderService   InvoiceService   UserService
                          |               |              |
authorization ------------+---------------+--------------+
tracing ------------------+---------------+--------------+
transactions -------------+---------------+--------------+

Those concerns do not belong exclusively to any one service. AOP gives us a vocabulary for selecting execution points and attaching behavior to them:

  • A join point is a supported point in program execution, such as a method call or method execution.
  • A pointcut selects a set of join points.
  • Advice runs before, after, or around the selected join points.
  • An aspect packages the selection and the behavior as one cross-cutting unit.

AOP diagram showing a pointcut selecting three join points and advice applying to only those selected points

That is the model used by AspectJ. Its pointcuts can select operations by type, method signature, annotation, control flow, and other context. The compiler or runtime then weaves the advice into the selected join points.

The important feature is not the punctuation. It is the separation between where behavior is selected and where the selected code was originally written. That separation is what lets one aspect affect many operations, and it is also what can make the resulting control flow difficult to trace.

Identify What TypeScript Can Actually Do

TypeScript has no built-in aspect, pointcut, before, after, or around declarations. It provides JavaScript plus a type system. Its decorator support is a metaprogramming mechanism, not a general pointcut language.

In TypeScript projects, “AOP” is casually used for several different mechanisms:

  • a higher-order function wrapping one operation;
  • middleware wrapping requests through a pipeline;
  • a framework interceptor wrapping selected handlers;
  • a decorator replacing a class method;
  • a library scanning metadata and patching methods at startup;
  • build-time code transformation.

All of them can implement cross-cutting behavior. They do not have the same selection rules, lifecycle, visibility, or failure modes.

I care less about winning the vocabulary argument than making the mechanism legible. If a pull request says it adds an aspect, I want to know what selects the code, when wrapping occurs, and what happens when the wrapper fails.

Start with the narrowest visible boundary. Confirm its contract. Introduce broader selection only when repetition creates a real maintenance problem.

Step 1: Wrap One Operation Explicitly

For one or two operations, I prefer a plain wrapper. There is no discovery process and no hidden matching rule:

import { performance } from "node:perf_hooks";

type NonThrowingTelemetry = {
  record(event: {
    operationName: string;
    status: "completed" | "failed";
    durationMs: number;
  }): void;
};

async function instrument<Result>(
  operationName: string,
  operation: () => Promise<Result>,
  telemetry: NonThrowingTelemetry,
): Promise<Result> {
  const startedAt = performance.now();
  let result: Result;

  try {
    result = await operation();
  } catch (error) {
    telemetry.record({
      operationName,
      status: "failed",
      durationMs: performance.now() - startedAt,
    });

    throw error;
  }

  telemetry.record({
    operationName,
    status: "completed",
    durationMs: performance.now() - startedAt,
  });

  return result;
}

The call site makes the wrapping visible:

const order = await instrument(
  "order.load",
  () => orderRepository.findById(orderId),
  telemetry,
);

The wrapper is repetitive if hundreds of methods need it, but repetition is not automatically the worst cost in a system. Explicit code is easy to search, easy to step through, and difficult to apply accidentally.

NonThrowingTelemetry is a behavioral contract; TypeScript cannot prove that a function will not throw. Enforce that failure policy in the telemetry adapter so an instrumentation failure cannot replace the business result.

Nothing requires discovery: the call site selects the operation, instrument applies the behavior at runtime, the generic return type and explicit rethrow preserve the operation’s result, and the telemetry adapter owns telemetry failures.

If dozens of stable class methods need the same treatment, those explicit calls can become maintenance noise. That is the point where I would consider moving one step outward.

Step 2: Promote a Stable Class Boundary to a Decorator

When the same stable behavior belongs on many class methods, a decorator can remove call-site repetition while keeping the selection visible on each declaration.

The following example uses the standard decorator model supported by TypeScript 5.0 and later:

import { performance } from "node:perf_hooks";

type OperationEvent = {
  operationName: string;
  status: "completed" | "failed";
  durationMs: number;
  errorType?: string;
};

type NonThrowingTelemetry = {
  record(event: OperationEvent): void;
};

type AsyncMethod<This, Args extends unknown[], Result> = (
  this: This,
  ...args: Args
) => Promise<Result>;

function instrumented(
  operationName: string,
  telemetry: NonThrowingTelemetry,
) {
  return function <This, Args extends unknown[], Result>(
    originalMethod: AsyncMethod<This, Args, Result>,
    _context: ClassMethodDecoratorContext<
      This,
      AsyncMethod<This, Args, Result>
    >,
  ): AsyncMethod<This, Args, Result> {
    return async function (
      this: This,
      ...args: Args
    ): Promise<Result> {
      const startedAt = performance.now();
      let result: Result;

      try {
        result = await originalMethod.apply(this, args);
      } catch (error) {
        telemetry.record({
          operationName,
          status: "failed",
          durationMs: performance.now() - startedAt,
          errorType: error instanceof Error
            ? error.name
            : "UnknownError",
        });

        throw error;
      }

      telemetry.record({
        operationName,
        status: "completed",
        durationMs: performance.now() - startedAt,
      });

      return result;
    };
  };
}

The decorator marks each selected method at its declaration:

type Order = {
  id: string;
};

declare const telemetry: NonThrowingTelemetry;
declare const database: {
  findOrderById(orderId: string): Promise<Order>;
};

class OrderRepository {
  @instrumented("order.load", telemetry)
  async findById(orderId: string): Promise<Order> {
    return database.findOrderById(orderId);
  }
}

Here, @instrumented selects the declaration, the decorator replaces the method when the class definition is evaluated, the returned function preserves this, arguments, promise behavior, results, and failures, and the telemetry adapter retains its non-throwing policy.

This still is not an AspectJ-style pointcut. The behavior is centralized, but the selection remains visible where the method is defined.

Check the Decorator Model

TypeScript has two decorator models in active projects:

  • The newer model follows the JavaScript decorators proposal and has been supported since TypeScript 5.0.
  • The older model is enabled with experimentalDecorators and predates the standardized proposal semantics.

They differ in signatures, capabilities, emitted code, and metadata behavior. The newer decorators are not compatible with emitDecoratorMetadata, and they do not support parameter decorators. Libraries built around legacy decorators may therefore require experimentalDecorators even when the TypeScript compiler supports the newer model.

Do not copy a decorator from a blog post, flip compiler flags until the red lines disappear, and call that architecture. Check which model the framework expects and type the decorator against that model deliberately. The TypeScript 5.0 decorator documentation shows the newer calling convention and its compatibility limits.

A decorator is still the wrong boundary when the concern follows a request rather than a particular method. In that case, move outward again and use the pipeline that already owns the request lifecycle.

Step 3: Prefer an Existing Pipeline Boundary

If the concern follows every HTTP request, decorating every controller method is manual pointcut maintenance. The request pipeline already provides a boundary:

request
  -> authentication middleware
  -> tracing middleware
  -> controller
  -> response

Middleware is usually the clearer fit for request IDs, request timing, headers, broad authentication, and response handling. If our timing requirement changes from “measure these repository operations” to “measure every HTTP request,” the selection rule belongs here. Framework interceptors can be appropriate when the concern needs handler metadata or framework-specific execution context.

The framework registration selects which requests pass through the middleware, pipeline order determines when it runs, and the middleware contract determines whether it must call the next handler or finish the response. Its error-handling rules also decide whether instrumentation failures reach the caller. The boundary changed; the design questions did not.

Match each concern to the narrowest boundary the system already provides:

ConcernBoundary to consider first
One operationExplicit wrapper
Every HTTP requestMiddleware
Selected framework handlersInterceptor or decorator
Database transactionTransaction callback or unit-of-work boundary
Calls through one clientClient wrapper
Distributed tracesFramework or OpenTelemetry instrumentation
Broad signature- or annotation-based selectionAOP library or transformation, if its machinery is justified

This is a boundary map, not a maturity ladder. Choose the narrowest existing boundary that covers the requirement. Broader selection should solve a demonstrated problem, because it also increases the distance between the rule and the affected code.

Step 4: Audit the Selection Distance

The greatest strength of a pointcut is also its greatest risk: one selection rule can affect code in many places.

A rule like “wrap every public service method” sounds efficient until the application adds a health check, a streaming operation, or a method that must not retry. Name-based rules can stop matching after a rename. Annotation-based rules can miss an override. Broad rules can instrument the instrumentation and recurse into a small observability bonfire.

The danger is not that AOP is inherently bad. The danger is distance:

selection rule --------> affected method --------> production failure
        ^                                             |
        +----------- not obvious in the diff --------+

As that distance grows, the project needs stronger receipts:

  • one discoverable place that defines selection;
  • narrow pointcuts with stable boundaries;
  • tooling that can report what matched;
  • tests for expected matches and expected non-matches;
  • documentation for ordering and failure behavior;
  • an escape hatch for exceptional cases.

If the team cannot answer “What wraps this method?” without tribal knowledge, the abstraction is spending more trust than it earns.

Step 5: Test Matching and Contract Preservation

Cross-cutting code sits near many important operations, so small mistakes spread widely. I would test at least these behaviors:

  1. The intended methods match, and unrelated methods do not.
  2. this, arguments, return values, and promise behavior are preserved.
  3. Synchronous throws and asynchronous rejections retain the original failure.
  4. Advice ordering is deterministic when more than one concern applies.
  5. Telemetry, cleanup, and transaction failures follow an explicit policy.
  6. Sensitive arguments and return values are not captured by default.
  7. Recursion and double instrumentation are prevented.
  8. The overhead is measured on hot paths instead of guessed.

Also test the build configuration. A perfectly reasonable decorator test is not useful if it runs under legacy decorator semantics while production compiles with the newer proposal-aligned model.

Decide Whether Broader Selection Earns Its Place

I would reach for a decorator, interceptor, or AOP library when all of the following are true:

  • The concern is persistent rather than a temporary debugging question.
  • It genuinely crosses many components.
  • The application already has a stable boundary or selection rule.
  • Centralizing the behavior prevents meaningful drift.
  • The team understands when and how the behavior is applied.
  • Tests prove matching, ordering, async behavior, failures, and non-matches.
  • The abstraction removes more cognitive load than it hides.

That high bar reflects the blast radius of cross-cutting behavior; “look how many lines we removed” is not enough evidence.

For temporary questions, use debugger-native tools instead of adding architecture. I cover watches, conditional breakpoints, and logpoints in Stop Committing Debug-Only Variables.

Use the Same Process Every Time

For each cross-cutting concern:

  1. Name the behavior and the operations it must affect.
  2. Find the narrowest boundary that already contains those operations.
  3. Make the selection rule visible and explain when wrapping occurs.
  4. Preserve the original inputs, outputs, this binding, async behavior, and failures.
  5. Test intended matches, non-matches, ordering, and failure policy.

AOP is not a synonym for decorators, and decorators are not a license to make control flow invisible.

Start with the boundary you can explain in one sentence. Move toward broader selection only when the concern is stable, the matching rule is trustworthy, and the team can see what the abstraction is doing.

Cross-cutting code should remove repetition. It should not remove understanding.