Home > Back-end >  Is there a better way to pipe Type Guard functions in TypeScript?
Is there a better way to pipe Type Guard functions in TypeScript?

Time:11-17

I have thought up the following to write a pipe function for Type Guards:

type PipedTypeGuard<A, B> = (
    value: A,
    ...args: readonly unknown[]
) => value is B extends A ? B : never;
type GuardPipeFn =  <A, B, C, D, E, F, G, H, I, J, K>(
    guard1: PipedTypeGuard<unknown, A>,
    guard2?: PipedTypeGuard<A, B>,
    guard3?: PipedTypeGuard<B, C>,
    guard5?: PipedTypeGuard<C, D>,
    guard6?: PipedTypeGuard<D, E>,
    guard7?: PipedTypeGuard<E, F>,
    guard8?: PipedTypeGuard<F, G>,
    guard9?: PipedTypeGuard<G, H>,
    guard10?: PipedTypeGuard<H, I>,
    guard11?: PipedTypeGuard<I, J>,
    guard12?: PipedTypeGuard<J, K>
) => (value: unknown) => value is A & B & C & D & E & F & G & H & I & J & K;

const guardPipe: GuardPipeFn = ()=>'not implemented'
// usage
const isFoobar = guardPipe(
     (val): val is string => typeof val === 'string'
     (val): val is `foo${string}` => val.startsWith('foo'), // (parameter) val: string
     (val): val is `foobar` => val === 'foobar' // (parameter) val: `foo${string}`
);
const test = {} as unknown;
if (isFoobar(test)) {
    test; // "foobar"
}

Playground Link

This works, but it only allows for a limited amount of parameters. It also feels a bit redundant to write this out. Is there a better, for example recursive way to do this? The main functionality I'm trying to achieve is that the Guard Type of the first is passed to the parameter of the next and so forth.

__ Some things I've tried:

/**
 * A type that a Guard will assign to a variable.
 * @example
 * ```ts
 * GuardType<typeof isString> // string
 * GuardType<typeof Array.isArray> // any[]
 * ```
 */
declare type GuardType<Guard extends any> = Guard extends (
    value: unknown,
    ...args: readonly any[]
) => value is infer U
    ? U
    : never;

// this will only work to infer the returned Guard Type of a array of Type Guards, but can not assign to individual parameters
type CombineGuardType<
    Arr extends ReadonlyArray<AnyTypeGuard>,
    Result = unknown
> = Arr extends readonly []
    ? Result
    : Arr extends readonly [infer Head, ...infer Tail]
    ? Tail extends readonly AnyTypeGuard[]
        ? CombineGuardType<Tail, Result & GuardType<Head>>
        : never
    : never;


CodePudding user response:

The TypeScript type inference algorithm is essentially a series of heuristics which works pretty well in a wide variety of scenarios, but has limitations. Anytime you write code in which you need the compiler to infer both generic type parameters and unannotated callback parameters contextually at the same time, there's a good chance that you will run into such limitations.

See microsoft/TypeScript#25826 for an example of this sort of problem. It is conceivably possible to implement a more rigorous unification algorithm, as discussed in microsoft/TypeScript#38872, but it's unlikely to happen in the near future.


The version of the code you have, with lots of distinct type parameters, works because it allows left-to-right inference from distinct function parameters. But any sort of abstraction to a single array-like type parameter means that you need to infer from a rest tuple, and things behave less well. For example, you could rewrite to the following variadic version where the type parameter T corresponds to the array of guarded types, so T corresponds to something like your [A, B, C, D, ...]:

type Idx<T, K> = K extends keyof T ? T[K] : any;
type Prev<T extends any[], I extends keyof T> = Idx<[any, ...T], I>
type Last<T extends any[]> = T extends readonly [...infer _, infer L] ? L : never;
type Guards<T extends any[]> = 
  { [I in keyof T]: (val: Prev<T, I>) => val is Extract<T[I], Prev<T, I>> }
function guardPipe<T extends unknown[]>(
  ...args: [...Guards<T>]): (val: any) => val is Last<T>;
function guardPipe(...args: ((val: any) => boolean)[]) {
    return (val: any) => args.every(p => p(val));
}

which works well as long as you free the compiler from having to infer the callback parameter types:

const p = guardPipe(
    (x: any): x is { a: string } => ("a" in x) && (typeof x.a === "string"),
    (x: { a: string }): x is { a: string, b: number } => 
      ("b" in x) && (typeof (x as any).b === "number"),
    (x: { a: string, b: number }): 
      x is { a: "hello", b: number } => x.a === "hello"
);

/* const p: (val: any) => val is {
    a: "hello";
    b: number;
} */


const val = Math.random() < 1000 ? { a: "hello", b: Math.PI } : { a: "goodbye", c: 123 };

if (p(val)) {
    console.log(val.b.toFixed(2)) // 3.14
}

and catches errors where your chain of types doesn't get progressively narrower:

const caughtError = guardPipe(
    (x: { a: string }): x is { a: string, b: number } => 
      ("b" in x) && (typeof (x as any).b === "number"),
    (x: any): x is { a: string } => ("a" in x) && (typeof x.a === "string"), // error!
    // Type predicate 'x is { a: string; }' is not assignable to 'val is never'.
    (x: { a: string, b: number }): 
      x is { a: "hello", b: number } => x.a === "hello"
)

but has problems as soon as you don't annotate callback parameters:

const oops= guardPipe(
    (x): x is { a: string } => ("a" in x) && (typeof x.a === "string"),
    (x): x is { a: string, b: number } => 
      ("b" in x) && (typeof (x as any).b === "number"),
    (x): x is { a: "hello", b: number } => x.a === "hello"
);
// const oops: (val: any) => val is never 
// uh oh

Here the generic type parameter T completely fails to be inferred, falls back to unknown[], and produces a guard of type (val: any) => val is never. Blah. So this is worse than your version.


In the absence of a more robust type inference algorithm, you would be better off playing to the compiler's strengths instead of its weaknesses if you want a truly general version of guardPipe(). For example, you can refactor from a single variadic function to a curried function or a fluent interface, where each function/method call only requires the inference of a single callback argument and a single type parameter:

type GuardPipe<T> = {
    guard: (val: unknown) => val is T;
    and<U extends T>(guard: (val: T) => val is U): GuardPipe<U>;
}
function guardPipe<T>(guard: (val: any) => val is T): GuardPipe<T>;
function guardPipe(guard: (val: any) => boolean) {

    function guardPipeInner(
      prevGuard: (val: any) => boolean, 
      curGuard: (val: any) => boolean
    ) {
        const combinedGuard = (val: any) => prevGuard(val) && curGuard(val);
        return {
            guard: combinedGuard,
            and: (nextGuard: (val: any) => boolean) =>
              guardPipeInner(combinedGuard, nextGuard)
        }
    }
    return guardPipeInner(x => true, guard) as any;
}

Here, if guard is of type (val: any) => val is T, then the call to guardPipe(guard) produces a value of type GuardPipe<T>. A GuardPipe<T> can either be used directly as a guard of that type by calling its guard method, or you can chain a new guard onto the end via its and method. The example from before then becomes:

const p = guardPipe(
    (x): x is { a: string } => x && ("a" in x) && (typeof x.a === "string")
).and((x): x is { a: string, b: number } => 
  ("b" in x) && (typeof (x as any).b === "number")
).and((x): x is { a: "hello", b: number } => x.a === "hello"
).guard;

const val = Math.random() < 1000 ? { a: "hello", b: Math.PI } : { a: "goodbye", c: 123 };

if (p(val)) {
    console.log(val.b.toFixed(2)) // 3.14
}

const oops = guardPipe(
    (x): x is { a: string, b: number } => ("b" in x) && (typeof (x as any).b === "number")
).and(
    (x): x is { a: string } => x && ("a" in x) && (typeof x.a === "string") // error!
    //  Type '{ a: string; }' is not assignable to type '{ a: string; b: number; }'
).and((x): x is { a: "hello", b: number } => x.a === "hello"
).guard;

which is quite similar and has the advantage of allowing the compiler to accurately infer the types for the type parameters without forcing you to annotate all those callback parameters.

Playground link to code

  • Related