有没有更好的方法在 TypeScript 中传递 Type Guard 函数?

Is there a better way to pipe Type Guard functions in TypeScript?

我想出以下方法来为 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

这可行,但它只允许有限数量的参数。把这个写出来也感觉有点多余。有没有更好的方法,例如递归的方法来做到这一点?我试图实现的主要功能是将第一个的 Guard Type 传递给下一个的参数,依此类推。

__ 我尝试过的一些事情:

/**
 * 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;


TypeScript 类型推断算法本质上是一系列启发式算法,适用于各种场景,但也有局限性。任何时候您编写的代码都需要编译器同时推断出 generic type parameters and unannotated callback parameters contextually,您很可能会 运行 进入此类限制。

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,但近期不太可能发生。


您拥有的代码版本(具有许多不同的类型参数)之所以有效,是因为它允许从不同的函数参数进行从左到右的推理。但是对单个类似数组的类型参数的任何抽象都意味着您需要从 rest tuple 进行推断,并且事情表现得不太好。例如,您可以重写为以下可变参数版本,其中类型参数 T 对应于受保护类型的数组,因此 T 对应于您的 [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));
}

只要您将编译器从推断回调参数类型中解放出来,它就可以很好地工作:

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
}

并捕获类型链没有逐渐变窄的错误:

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"
)

但一旦不注释回调参数就会出现问题:

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

这里泛型类型参数 T 完全无法推断,退回到 unknown[],并产生类型 (val: any) => val is never 的守卫。废话。所以这比你的版本更糟糕。


在没有更健壮的类型推断算法的情况下,如果您想要 guardPipe() 的真正通用版本,您最好发挥编译器的优势而不是其劣势。例如,您可以将单个可变参数函数重构为 curried function or a fluent interface,其中每个 function/method 调用仅需要推断单个回调参数和单个类型参数:

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;
}

这里,如果 guard(val: any) => val is T 类型,那么对 guardPipe(guard) 的调用会产生一个 GuardPipe<T> 类型的值。 GuardPipe<T> 可以通过调用其 guard 方法直接用作该类型的守卫,或者您可以通过其 and 方法将新守卫链接到末尾。之前的例子变成:

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;

这非常相似,并且具有允许编译器准确推断类型参数类型的优点,而无需强制您注释所有这些回调参数。

Playground link to code