Issue
I am currently wading through a complex custom typeguard library written for a project I'm working on, and I'm having problems understanding the way that function signatures work for typeguards.
There is a generic Is function that takes the following form:
type Is<A> = (a: unknown) => a is A
This allows me to write composable typeguards of the form
const isString: Is<string> = (u: unknown): u is string => typeof u === 'string'
const isNumber: Is<number> = (u: unknown): u is number => typeof u === 'number'
There are also ones for records, structs, arrays and so on. For example, the array one is
const isArray = <A>(isa: Is<A>) => (u: unknown): u is A[] => Array.isArray(u) && u.every(isa)
And the one used for objects is
export const isStruct = <O extends { [key: string]: unknown }>(isas: { [K in keyof O]: Is<O[K]> }): Is<O> => (
o
): o is O => {
if (o === null || typeof o !== 'object') return false
const a = o as any
for (const k of Object.getOwnPropertyNames(isas)) {
if (!isas[k](a[k])){
return false
}
}
return true
}
For example:
const isFoo: Is<{foo: string}> = isStruct({foo: isString})
We currently have a very basic overloaded isIntersection function:
export function isIntersection<A, B>(isA: Is<A>, isB: Is<B>): (u: unknown) => u is A & B
export function isIntersection<A, B, C>(isA: Is<A>, isB: Is<B>, isC: Is<C>): (u: unknown) => u is A & B & C
export function isIntersection<A, B, C>(isA: Is<A>, isB: Is<B>, isC?: Is<C>) {
return (u: unknown): u is A & B & C => isA(u) && isB(u) && (!isC || isC(u))
}
Naturally, the problem is that if you want to have a fourth or fifth typeguard added to this, you need to nest isIntersection typeguards, which isn't great.
Based on some great answers by @jcalz, particularly Typescript recurrent type intersection, I have the following type:
type Intersection<A extends readonly any[]> =
A[number] extends infer U ?
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ?
I : never : never;
And I think I can write the actual guard as something like this:
export function isIntersection<T extends any[]>(...args: { [I in keyof T]: Is<T[I]> }): Is<Intersection<T>>
{
return (u: unknown): u is Intersection<T[number]> => args.every((isX) => isX(u))
}
This works, but I don't know how the Intersection type is able to correctly infer the type.
I'm deeply grateful to @jcalz for answers and for pushing me to be clearer.
Solution
The approach I'd suggest here is to make the isIntersection generic type parameter T correspond to the tuple of the types you're guarding for (the type argument to Is<T>). So if you call have isA of type Is<A> and isB of type Is<B>, then isIntersection(isA, isB) should be generic in the type [A, B]. We can then use mapped tuple types to represent both the args input and the return type in terms of T.
Something like this:
function isIntersection<T extends any[]>(
...args: { [I in keyof T]: Is<T[I]> }
): Is<IntersectTupleElements<T>> {
return (u: unknown): u is IntersectTupleElements<T> =>
args.every((isX) => isX(u))
}
The args list is a mapped type where we take each element of T and wrap it with Is<>. So if T is [A, B], then args is of type [Is<A>, Is<B>]. The output type is Is<IntersectTupleElements<T>> where IntersectTupleElements<T> should take a tuple type like [A, B] and evaluate to the intersection of the elements of that tuple, like A & B.
Here's one way to implement that:
type IntersectTupleElements<T extends any[]> =
{ [I in keyof T]: (x: T[I]) => void }[number] extends
(x: infer I) => void ? I : never;
This uses a similar approach as UnionToIntersection<T> from this question/answer, but it explicitly walks over tuple elements instead of union members. If T is a type like [A | B, C] you want IntersectTupleElements<T> to be (A | B) & C, but if you blur [A | B, C] to (A | B | C)[] first, you'll get A & B & C which is not what you want.
Anyway, let's try it out:
interface A { a: string }
interface B { b: number }
const isA: Is<A> = isStruct({ a: isString });
const isB: Is<B> = isStruct({ b: isNumber });
const isAB = isIntersection(isA, isB)
// function isIntersection<[A, B]>(args_0: Is<A>, args_1: Is<B>): Is<A & B>
// const isAB: Is<A & B>
Looks good! The call to isIntersection(isA, isB) causes the compiler to infer the type T to be [A, B], from which the return type is calculated as Is<A & B>. You can verify that this is variadic, so isIntersection(isA, isB, isC, isD, isE will result in a value of type Is<A & B & C & D & E>, etc.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.