2

I need something similar to a chain-of-responsibility pattern, where I can have a chain of processors and a state object, and as each processor is called, it can:

  • Read the state object
  • Add, modify, or remove fields from the state object
  • Decide whether the request is completely satisfied and no further processors should be called, or if processing should continue with the next processor in the chain

I need these processors to be type-safe. The shape of the state object will change depending on the processors that are added to the chain.

To support the last requirement - controlling the execution flow (stop or continue after each processor is done), I wanted to use callbacks; if your processor wants processing to continue, it calls the callback; if your processor completed the processing and no other processors should run, it just does not call the callback. This is similar to middleware / request handlers in Express, for example.

But I'm having a problem when I try to reference the state object's type in defining the type that the callback accepts. If the processor makes no modifications to the type of the state object, everything works fine. But when I try to declare that a processor will modify the state object type, I lose the type on the state object for some reason. I'm sure there's something I'm not understanding about type inference.

TypeScript Playground example

Abbreviated version of that:

export type PipelineNext<TResult> = (result: TResult) => void;

export type PipelineHandler<T, TResult> = (state: T, next: PipelineNext<TResult>) => void;

export interface Pipeline<T> {
   then<TOut = T>(fn: PipelineHandler<T, TOut>): Pipeline<TOut>;
   end: () => void;
}

pipeline({ something: 'starting state' })
   // The pipeline starts with an empty state object, which is passed into
   // the first handler.
   .then((state, next) => {
      console.info(state.something);
      next(state);
   })
   // And I can transform the state object into a completely different type
   // just by defining the type on my PipelineNext.
   .then((state, next: PipelineNext<{ original: string, name: string }>) => {
      next({ original: state.something, name: 'foo' });
   })
   // And I get that state here, as expected
   .then((state, next) => {
      console.info(state.original);
      console.info(state.name);
      next(state);
   })
   // Here's where the problem starts ... let's say instead of redefining the
   // response type, I want to simply amend it with something, and my processor
   // doesn't know the type of the incoming state, so it can't redefine it.
   // EXAMPLE 1: Use Omit to remove a field from the state object.
   // PROBLEM: `state` becomes `any` here, which makes it a bit useless inside
   // the handler.
   .then((state, next: PipelineNext<Omit<typeof state, 'name'>>) => {
      // Now state is `any`, which is useless inside this function
      next({ original: state.original });
      // But the callback is typed correctly (as demonstrated by
      // uncommenting the next line)
      // next({ foo: 'bar' });
   })

It took me a while to even get the callback typing all correct, and the chaining correct, where each call to .then gets the type from the callback. I feel like I'm real close, but just am not sure why the type inference is lost on state when the processor is modifying that type, but works fine when there's no reference to the type.

Jeremy Thomerson
  • 722
  • 2
  • 8
  • 19
  • Wow, yuck. See [this question](https://stackoverflow.com/questions/65431379/type-property-relying-on-return-type-of-another-property) for a general overview of the problem; the compiler really doesn't do well inferring *both* generic type parameters *and* the contextual type of a callback parameter at the same time. It's mostly one or the other. You will either have to be more explicit with types than you want, either by spelling out the output type like [this code](https://tsplay.dev/w6BPEw), or giving strong hints about what you're doing like [this code](https://tsplay.dev/WPjR5N). – jcalz Jul 28 '21 at 02:09
  • Either way is not what you want, but I don't see much of an alternative. This question isn't exactly a duplicate of [this one](https://stackoverflow.com/questions/65431379/type-property-relying-on-return-type-of-another-property) but it's in the same spirit and my answer would be very similar. Not sure what we want to see here; maybe someone else will come along with some insight I'm missing here. – jcalz Jul 28 '21 at 02:11

1 Answers1

0

I believe it is very hard to type the code you have provided because TypeScript is unable to infer the return type of then callback, because you return nothing from it.

I think it worth to move calling of next callback into implementation and just return the value from then callback which you want to pass into next callback.

Consider this example:


export type PipelineHandler<State, Result> = (state: State) => Result;

export interface Pipeline<T> {
   then<Result>(fn: PipelineHandler<T, Result>): Pipeline<Result>;
   end: () => void;
}

const removeProperty = <Obj, Prop extends keyof Obj>(obj: Obj, prop: Prop) => {
   const { [prop]: _, ...rest } = obj;

   return rest
}
pipeline({ something: 'starting state', name: 'John Doe' })
   .then((state) => removeProperty(state, 'name'))
   .then((state) => {
      state.something // ok
      state.name // expected error
   })


export function pipeline<T>(initialState: T): Pipeline<T> {
   return null as any
}

As you might have noticed, state argument in second then is infered properly. Hence, you can call next function with result of then callback inside your pipeline function.

Btw, avoid mutations. TypeScript does not track mutations very well. See my article. I believe it is doable to handle this pattern without mutation the handlers array.

Anoher one example of chaining pattern, you can find in my blog. This is one of 3 examples from the article:

type Fn = (...args: any[]) => any

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
   k: infer I
) => void
   ? I
   : never;

export interface Pipeline<Ctx> {
   <A>(
      conditions: [(context: Ctx) => A],
      body: (context: A) => any
   ): any

   <C extends Array<Fn>>(
      conditions: C,
      body: (context: UnionToIntersection<ReturnType<C[number]>>) => void
   ): any
}

const removeProperty = <Obj, Prop extends PropertyKey>(obj: Obj, prop: Prop) => {
   const { [prop]: _, ...rest } = obj;

   return rest
}

const pipeline: Pipeline<{ hello: 'world' }> = () => void 0


const withWorld = <Context extends { hello: 'world' }>(context: Context) => ({ ...context, world: 'hello' })
const withSomething = <Context extends {}>(context: Context) => ({ ...context, something: 'else' })
const withSomethingElse = <Context extends {}>(context: Context) => (removeProperty(context, 'something'))


pipeline([withSomething], (_context) => void 0)
pipeline([withWorld], (_context) => void 0)
pipeline([withWorld, withSomething, withSomethingElse], (context) => {
   context.hello // ok
   context.world // ok
})

Playground

As you see, context argument at the end is infered as expected. See related question/answer. It will gives you some context.

And the last one is old good compose or pipe function.Full explanation is in my blog. Example:


type Foo = typeof foo
type Bar = typeof bar
type Baz = typeof baz


type Fn = (a: any) => any

type Head<T extends any[]> =
    T extends [infer H, ...infer _]
    ? H
    : never;

type Last<T extends any[]> =
    T extends [infer _]
    ? never : T extends [...infer _, infer Tl]
    ? Tl
    : never;


type Allowed<
    T extends Fn[],
    Cache extends Fn[] = []
    > =
    T extends []
    ? Cache
    : T extends [infer Lst]
    ? Lst extends Fn
    ? Allowed<[], [...Cache, Lst]> : never
    : T extends [infer Fst, ...infer Lst]
    ? Fst extends Fn
    ? Lst extends Fn[]
    ? Head<Lst> extends Fn
    ? Head<Parameters<Fst>> extends ReturnType<Head<Lst>>
    ? Allowed<Lst, [...Cache, Fst]>
    : never
    : never
    : never
    : never
    : never;

type LastParameterOf<T extends Fn[]> =
    Last<T> extends Fn
    ? Head<Parameters<Last<T>>>
    : never

type Return<T extends Fn[]> =
    Head<T> extends Fn
    ? ReturnType<Head<T>>
    : never


function compose<T extends Fn, Fns extends T[], Allow extends {
    0: [never],
    1: [LastParameterOf<Fns>]
}[Allowed<Fns> extends never ? 0 : 1]>
    (...args: [...Fns]): (...data: Allow) => Return<Fns>

function compose<
    T extends Fn,
    Fns extends T[], Allow extends unknown[]
>(...args: [...Fns]) {
    return (...data: Allow) =>
        args.reduceRight((acc, elem) => elem(acc), data)
}

const foo = (arg: 1 | 2) => [1, 2, 3]
const bar = (arg: string) => arg.length > 10 ? 1 : 2
const baz = (arg: number[]) => 'hello'

const check = compose(foo, bar, baz)([1, 2, 3]) // [number]
const check2 = compose(bar, foo)(1) // expected error

Playground

Related questions can be found here and here.