Richard Coates
Richard Coates

Reputation: 82

Typeguard for intersection with variable number of elements

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.

Playground

Upvotes: 1

Views: 131

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 1

Related Questions