Reputation: 82
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.
Upvotes: 1
Views: 131
Reputation: 329198
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.
Upvotes: 1