Search code examples
typescriptdecoratortypescript-genericsreducefunction-composition

Decorators for functions in TypeScript


I'm trying to make a statically type-checked "decorators" for functions. Basically, it is a helper for function composition from the right to remove the nesting.

The issue is that while types are inferred fine in one direction (from the input to the handler (the last argument)), they aren't inferred in the opposite direction. How to fix that?

// Should infer type Handler<InputContext, { a: string, b: boolean }>, but gets Handler<InputContext, unknown>
const myHandler2 = decorate(
  addToInput((_c: InputContext) => ({ y: 10 })),
  addToOutput(_r => ({ b: true })),
  async ({ x, y }): Promise<OutputContext> => {
    // Both x and y are inferred fine!

    return {
      a: `x = ${x + y}`
    };
  }
);

TypeScript Playground with full code

Full code:

type Merge<A, B> = Omit<A, keyof B> & B;

// Context is just an object with input data
type Handler<I, O> = (
  context: I
) => Promise<O>;

// Wraps a Handler
type Decorator<I, NI, NO, O> = (
  next: Handler<NI, NO>
) => Handler<I, O>;

function decorate<Input, Output>(
  handler: Handler<Input, Output>
): Handler<Input, Output>;
function decorate<A, Input, Output, Z>(
  mw1: Decorator<A, Input, Output, Z>,
  handler: Handler<Input, Output>
): Handler<A, Z>;
function decorate<A, B, Input, Output, Y, Z>(
  mw1: Decorator<A, B, Y, Z>,
  mw2: Decorator<B, Input, Output, Y>,
  handler: Handler<Input, Output>
): Handler<A, Z>;
function decorate<A, B, C, Input, Output, X, Y, Z>(
  mw1: Decorator<A, B, Y, Z>,
  mw2: Decorator<B, C, X, Y>,
  mw3: Decorator<C, Input, Output, X>,
  handler: Handler<Input, Output>
): Handler<A, Z>;
function decorate(
  ...handlers: Function[]
) {
  return handlers.reduceRight((acc, h) => acc ? h(acc) : h)
}

interface InputContext {
  x: number;
}

interface OutputContext {
  a: string;
}

// Successfully infers Handler<InputContext, OutputContext>
const myHandler0 = decorate(
  async ({ x }: InputContext): Promise<OutputContext> => {
    return {
      a: `x = ${x}`
    };
  }
);

myHandler0({ x: 5 })
  .then(o => console.log(`myHandler0: output = ${JSON.stringify(o)}`))
  .catch(e => console.error(`myHandler0: `, e));

// Basic decorator, does nothing
const passthrough = <I, O>(): Decorator<I, I, O, O> =>
  next => context => next(context);

// Successfully infers type Handler<InputContext, OutputContext>
const myHandler1 = decorate(
  passthrough(),
  async ({ x }: InputContext): Promise<OutputContext> => {
    return {
      a: `x = ${x}`
    };
  }
);

myHandler1({ x: 5 })
  .then(o => console.log(`myHandler1: output = ${JSON.stringify(o)}`))
  .catch(e => console.error(`myHandler1: `, e));

// More advanced decorators that change Input and/or Output types
const addToInput = <I, F, O>(factory: (context: I) => F): Decorator<I, Merge<I, F>, O, O> =>
  next => context => next({ ...context, ...factory(context) });

const addToOutput = <I, O, F>(factory: (context: O) => F): Decorator<I, I, O, Merge<O, F>> =>
  next => context => next(context).then(c => ({ ...c, ...factory(c) }) as Merge<O, F>);

// Should infer type Handler<InputContext, { a: string, b: boolean }>, but gets Handler<InputContext, unknown>
const myHandler2 = decorate(
  addToInput((_c: InputContext) => ({ y: 10 })),
  addToOutput(_r => ({ b: true })),
  async ({ x, y }): Promise<OutputContext> => {
    // Both x and y are inferred fine!

    return {
      a: `x = ${x + y}`
    };
  }
);

myHandler2({ x: 5 })
  .then(o => console.log(`myHandler2: output = ${JSON.stringify(o)}`))
  .catch(e => console.error(`myHandler2: `, e));

// Should infer type Handler<InputContext, { a: string, b: boolean }>, but gets Handler<InputContext, unknown>
const myHandler3 = decorate(
  addToInput((_c: InputContext) => ({ y: 10 })),
  passthrough(),
  addToOutput(_r => ({ b: true })),
  async ({ x, y }): Promise<OutputContext> => {
    // Both x and y are inferred fine!

    return {
      a: `x = ${x + y}`
    };
  }
);

myHandler3({ x: 5 })
  .then(o => console.log(`myHandler3: output = ${JSON.stringify(o)}`))
  .catch(e => console.error(`myHandler3: `, e));

Solution

  • The issue is that while types are inferred fine in one direction (from the input to the handler (the last argument)), they aren't inferred in the opposite direction. How to fix that?

    When working with TypeScript's advanced type system, I have found it useful to follow one rule: don't try to be too clever, otherwise you end up with really really difficult to understand code!

    Having said that, I don't think there's a way to fix the problem you are facing because type inference in TypeScript works in one direction (left-to-right).

    You may be able to find the truth of the matter in these two PRs both of which improve features around function composition:

    1. Variadic tuple types
    2. Higher order function type inference

    That second PR contains sentences like "...in the left-to-right processing of the function call arguments..." and "...as the arguments are processed left-to-right..." which are strong hint that inference is NOT bidirectional as your situation would like it to be.