2

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;


Nico
  • 872
  • 10
  • 16
  • I thought that this https://stackoverflow.com/questions/69616906/how-can-i-strongly-type-a-composed-mixin/69627763#69627763 or this https://catchts.com/FP-style#compose might be useful but it seems impossible to infer guard predicate. – captain-yossarian from Ukraine Nov 15 '21 at 16:50
  • I use this to infer the predicate: ` type GuardType = G extends ( value: unknown, ...args: readonly any[] ) => value is infer U ? U : never; ` – Nico Nov 15 '21 at 16:58
  • I see. Also you use explicit generics. I have tried to infer a predicate from the function itself, without explicit generics – captain-yossarian from Ukraine Nov 15 '21 at 17:02
  • Try to create higher order function and pass typeguard to it and then try to infer type from condition – captain-yossarian from Ukraine Nov 15 '21 at 17:06
  • When you say "this works", I'm not sure what you mean because [I see a bunch of errors](https://tsplay.dev/NVganm). Could you make sure your code is a [mre] that clearly demonstrates the issue without having any unrelated errors or problems? – jcalz Nov 15 '21 at 18:37
  • Is there a reason you're doing a big intersection instead of just using the last type parameter? Generally a type guard function `(x: A) => x is B` only works if `B extends A` so `A & B` should be essentially equivalent to just `B`, and so `A & B & C & ... & Z` will end up essentially equivalent to `Z`, and it's much easier and nicer to deal with the latter than the former. Do you have a use case where that simplification will not work for you? – jcalz Nov 15 '21 at 18:57
  • You are right, and I've done that too. What I want is that the compiler at least turns to never when some of the guards mismatch, hence the A & B. Null & string is never for example – Nico Nov 15 '21 at 19:06
  • You can do [this](https://tsplay.dev/WoJDlm) but you need to manually annotate your type guard callback parameters, because the compiler cannot really infer both the generic type parameter *and* the callback parameters from context at the same type (see https://github.com/microsoft/TypeScript/issues/38872). Personally I'd suggest that you don't try to fight with TS and instead write fluent/curried version like [this](https://tsplay.dev/mqQEQm), if that works for you. It should give an error if you pass in a bad type guard in the chain. – jcalz Nov 15 '21 at 19:27
  • I'm happy to write any of that up as an answer if any of those approaches meets your needs. Let me know. – jcalz Nov 15 '21 at 19:28
  • About the code not working, I've added a Playground link with working example. I'm willing to accept "it's not possible" if we simply can't get anything more flexible than what I already have, since it does do a good job at inferring the parameters as well as the return type. When I look at fp-ts, I see a similar implementation (with all possible parameters written out) so it's likely that it can't be done like I want to – Nico Nov 15 '21 at 21:30
  • 1
    I think the second example you gave, it's a good alternative. – Nico Nov 15 '21 at 22:32

1 Answers1

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Finally a good starting point to explore why TS is rather limited when it comes to parametric polymorphic higher order functions. Thank you! –  Nov 16 '21 at 10:43