Wait... what are we doing?
Okay, so, in a lot of new projects I want to write a functional pipeline. In other languages you might have threading macros (shoutouts to Clojure, Elixir), or you might have nifty Hindley-Milner-powered type system that can express the composition of arbitrary functions, and/or auto-currying.
But we're in Typescript, so we have to staple together a bunch of utilities to get something similar. I'm going to show you some recipes you can copy-paste into your own project, in case you're just as alergic to dependencies as I am.
Some pre-requisites
Okay, first we're going to assemble some type utilties that will help us out later.
type Not<T extends boolean> = T extends true ? false : true;
type And<A extends boolean, B extends boolean> = A extends false
? false
: B extends false
? false
: true;
type Or<A extends boolean, B extends boolean> = A extends false
? B extends false
? false
: true
: true;
type Assignable<T, U> = [T] extends [U] ? true : false;
type ShapesMatch<T, U> = And<Assignable<T, U>, Assignable<U, T>>;
type TypesMatch<T, U> = And<
ShapesMatch<T, U>,
ShapesMatch<keyof T, keyof U>
>;
Oh yeah, you know we're getting into typescript crimes when we break out the logical operators.
The interesting ones are are Assignable<T, U>, ShapesMatch<T, U>, and TypesMatch<T, U>. Assignable<T, U> uses the fact that we can use tuple types to get around conditional types distributing over unions, check out the Frontend Masters Blog if you're not familiar with the pitfalls here.
Once you understand why we need Assignable<T, U>, ShapesMatch<T, U> and TypesMatch<T, U> are easy to grok. ShapesMatch<T, U> just makes sure that assignability goes both directions. If T extends U and U extends T then we can almost be certain that T == U. TypesMatch<T, U> gets us the rest of the way, ensuring that all of the keys of T and U also match, to avoid pitfalls around optional and readonly keys.
Let the games begin
Okay, let's do a little design.
We want a function that has this shape:
function pipe(...pipeline: Function[]): (/* ??? */) => /* ??? */ { /* ??? */ }
const myPipeline = pipe(
(n: number) => string,
(s: string) => symbol,
(s: symbol) => `${number}-${boolean}`,
(s: `${number}-${boolean}`) => boolean,
);
console.log(typeof myPipeline('hi mom!')); // prints 'boolean'
We're going to take in a variadic array of functions, and compose them together, so that we get a new function that takes an argument that matches the type of the first function, and returns a value that matches the type of the last function.
First off, we know how to write the body of this function if this was JS:
function pipe(...pipeline) {
// reduce over our pipeline
return (v) => pipeline.reduce(
// at each step we apply the current function
(carry, current) => current(carry),
// given our initial value
v
);
}
So we're really just talking about how to do the following:
- Produce a return type for
pipethat tells us how to use that value. - Produce a typecheck error when we screw up the alignment of types in the pipeline.
How do we write that return type?
Let's look at our earlier shape:
function pipe(...pipeline: Function[]): (/* ??? */) => /* ??? */ { /*...*/ }
We need to be able to write the type of the parameters of this returned function, and we need to be able to write the return type of that function as well.
Logically, we know that the parameter to this function is the type of the parameter of the first function that we place in the pipeline, and the return type of this function is the return type of the last function in the pipeline:
const myPipeline = pipe(
(n: number) => string,
(s: string) => symbol,
(s: symbol) => `${number}-${boolean}`,
(s: `${number}-${boolean}`) => boolean,
)
In this example, myPipeline has the type (n: number) => boolean.
How can we express this in a type?
type FirstArg<P> = /* ??? */;
type LastReturn<P> = /* ??? */;
Let's tackle FirstArg<P> first.
We know that we're going to be dealing with an array of function types. So what is our base case?
type FirstArg<P> =
P extends []
? never
: /* ??? */;
If we have an empty pipeline, there's no sensible type we can give this function that will do anything.
Next, we'll use variadic tuple type inference to pry off the first function:
type FirstArg<P> =
P extends[]
? never
: P extends [infer F, ...infer _]
? /* ??? */
: never
And then we can infer the arg type we'll return
type FirstArg<P> =
P extends[]
? never
: P extends [infer F, ...infer _]
? F extends (arg: infer Arg) => infer _
? Arg
: never
: never
Let's test it out!
// emits a type error if T is false
type Expect<T extends true> = T;
type GoodFunctionPipeline = [
(n: number) => string,
(s: string) => symbol,
(s: symbol) => `${number}-${boolean}`,
(s: `${number}-${boolean}`) => boolean,
];
// yup!
type FirstArgMatches = Expect<
TypesMatch<number, FirstArg<GoodFunctionPipeline>>
>;
Using these same principles, we can derive LastReturn. We just change up where we do our ...infer _ and return the return type instead
export type LastReturn<P> =
P extends []
? never
// grab the last one instead of the first
: P extends [...infer _, infer F]
? F extends (arg: infer _) => infer Return
? Return
: never
: never;
// nice!
type LastReturnMatches = Expect<
TypesMatch<boolean, LastReturn<GoodFunctionPipeline>>
>;
Not bad! And we can use the lessons we've learned to validate our pipeline as well.
We want a type that will take in a tuple of function types and return the exact same tuple if the types line up, and never otherwise.
type Pipeline<P> = /* ??? */
Well, again, we're dealing with an array of functions, so let's handle our base case
type Pipeline<P>
P extends []
? []
: /* ??? */
Empty pipelines are vacuously fine.
Now we know we have a non-empty pipeline, we have to:
- iterate over each function
- compare its return type to the parameter type of the next function
But in the language of typescript types, our only option for iteration is recursion. So we'll use variadic type inference again to pry off the first item off the tuple, we'll check some condition (TBD) to see if we should continue, and then do our recursion.
type Pipeline<P>
P extends []
? []
: P extends [infer F, ...infer Fs]
? F extends (arg: infer Arg) => infer Return
? /* ??? */
? [F, ...Pipeline<Fs>]
: never
: never
: never;
Now what does that condition look like?
First of all, if it's the first function of the pipeline, we want to just continue on, accepting it. For each other function in the pipeline, we want to compare the return type of the previous function to the parameter type of the current function.
When we're using recursion, we have to carry our state with us in the recursive call
type Pipeline<P, Prev = never>
P extends []
? []
: P extends [infer F, ...infer Fs]
? F extends (arg: infer Arg) => infer Return
? Or<TypesMatch<Prev, never>, /* ??? */> extends true
? [F, ...Pipeline<Fs, Return>]
: never
: never
: never;
Now we have access to the previous return value, we can compare:
type Pipeline<P, Prev = never>
P extends []
? []
: P extends [infer F, ...infer Fs]
? F extends (arg: infer Arg) => infer Return
? Or<TypesMatch<Prev, never>, Assignable<Prev, Arg>> extends true
? [F, ...Pipeline<Fs, Return>]
: never
: never
: never;
If Prev is assignable to Arg we can continue, completing our recursive call.
Some tests:
type GoodFunctionPipeline = [
(n: number) => string,
(s: string) => symbol,
(s: symbol) => `${number}-${boolean}`,
(s: `${number}-${boolean}`) => boolean,
];
type BadFunctionPipeline = [(n: number) => string, (n: number) => boolean];
// Pipeline<GoodFunctionPipeline> should act like a no-op!
type GoodPipelineIsGood = Expect<
TypesMatch<
GoodFunctionPipeline,
Pipeline<GoodFunctionPipeline>
>
>;
type BadPipelineIsBad = Expect<
Not<TypesMatch<BadFunctionPipeline, Pipeline<BadFunctionPipeline>>>
>;
Nice!
So with our typing done, we can plug it into our implementation:
type Pipeline<P, Prev = never> = P extends []
? []
: P extends [infer F, ...infer Fs]
? F extends (arg: infer Arg) => infer Return
? Or<TypesMatch<Prev, never>, Assignable<Arg, Prev>> extends true
? [F, ...Pipeline<Fs, Return>]
: never
: never
: never;
type FirstArg<P> = P extends []
? never
: P extends [infer F, ...infer _]
? F extends (arg: infer Arg) => infer _
? Arg
: never
: never;
type LastReturn<P> = P extends []
? never
: P extends [...infer _, infer F]
? F extends (arg: infer _) => infer Return
? Return
: never
: never;
function pipe<
P extends Function[],
>(...pipeline: Pipeline<[...P]>): (arg: FirstArg<P>) => LastReturn<P> {
return (v) =>
(pipeline as [...P]).reduce(
(carry, current) => current(carry),
v,
) as unknown as Return;
}
You'll notice the use of this Pipeline<[...P]> trick helps typescript infer things as a tuple rather than an array of a union.
And then we meet the reality that as much as we'd like perfect type-safety, sometimes we have to reach for the blunt tool of as T. Array.prototype.reduce is typed the way that it is, and that typing does not include carry changing type between each call.
Here's all the code in one last codeblock for you copy-paster's out there:
type And<A extends boolean, B extends boolean> = A extends false
? false
: B extends false
? false
: true;
type Or<A extends boolean, B extends boolean> = A extends false
? B extends false
? false
: true
: true;
type Assignable<T, U> = [T] extends [U] ? true : false;
type ShapesMatch<T, U> = And<Assignable<T, U>, Assignable<U, T>>;
type TypesMatch<T, U> = And<
ShapesMatch<T, U>,
ShapesMatch<keyof T, keyof U>
>;
type Pipeline<P, Prev = never> = P extends []
? []
: P extends [infer F, ...infer Fs]
? F extends (arg: infer Arg) => infer Return
? Or<TypesMatch<Prev, never>, Assignable<Arg, Prev>> extends true
? [F, ...Pipeline<Fs, Return>]
: never
: never
: never;
type FirstArg<P> = P extends []
? never
: P extends [infer F, ...infer _]
? F extends (arg: infer Arg) => infer _
? Arg
: never
: never;
type LastReturn<P> = P extends []
? never
: P extends [...infer _, infer F]
? F extends (arg: infer _) => infer Return
? Return
: never
: never;
function pipe<
P extends Function[],
>(...pipeline: Pipeline<[...P]>): (arg: FirstArg<P>) => LastReturn<P> {
return (v) =>
(pipeline as [...P]).reduce(
(carry, current) => current(carry),
v,
) as unknown as LastReturn<P>;
}
Come back next time, when we talk about how to apply a similar technique to drive Express-style middleware!