1

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

  • What isUnion and isIntersection should do ? – captain-yossarian from Ukraine Jun 09 '22 at 11:51
  • This asks multiple questions; could you please focus on just one of them? In any case this doesn't seem to be a [mre] (and you should fix typos, like whatever is going on in your definition of `isUnion`). The `isIntersection()` call signature doesn't work out-of-the-box, and results in `Is`. I wouldn't write `isUnion()` the way they did either. To fix both of them looks something like [this](//tsplay.dev/NlEx5m), and while I could explain how *my* versions work, I don't know what to say about some third-party code which isn't even demonstrated. What should we do here? – jcalz Jun 09 '22 at 19:04
  • @jcalz Thank you so much for your assistance. I have edited the question to provide examples, and have moved the library to a public repo in case it helps: https://github.com/ByteLondon/byteguards. I understand your isUnion signature, could you explain the isIntersection one? – Richard Coates Jun 10 '22 at 15:18
  • You are still asking multiple questions. "Why is X and Y the way they are" is two questions, "why is X the way it is" and "why is Y the way it is". They have different answers. – jcalz Jun 10 '22 at 15:29
  • @jcalz Now focussing just on the IsIntersection. – Richard Coates Jun 10 '22 at 15:38
  • Your `isIntersection()` implementation has compiler warnings about `(u: unknown)=>boolean` not being assignable to `Is`. And `isStruct()` is not defined. Please consult the guidelines for what makes a [mre]. – jcalz Jun 10 '22 at 15:42
  • @jcalz Fixed, and included the GitHub repo for all of the different typeguards. – Richard Coates Jun 10 '22 at 15:46
  • What is the desired result from `isIntersection` and What are you currently getting? – thurasw Jun 10 '22 at 15:51
  • [This is what I see with that code](https://tsplay.dev/Na2bym). The error in the call to `isIntersection()`, is that what you're asking about? If not, why is an error there? I would love to get to a point where I see what's supposed to be going on in order to answer it. So far I'd say that, for all I've seen, the code is the way it is because it's fundamentally broken, or can't be used properly, or can't *easily* be used properly, and should be changed regardless. There are ways to turn a tuple type into an intersection of its members, and that's not what's going on here. – jcalz Jun 10 '22 at 15:52
  • Please make sure that your code demonstrates what you want it to demonstrate when pasted into a standalone IDE. The TS Playground is a good "standard" IDE for you to play around with. I'm hoping we don't have to do any more rounds of code editing to get on the same page. – jcalz Jun 10 '22 at 15:53
  • @jcalz I have fundamentally changed the question, based on the last hour of reading your other answers to similar questions. I hope that there is now a possible answer. – Richard Coates Jun 10 '22 at 16:55
  • 1
  • @jcalz Thank you so much, I think that's it! Please could you write up the answer? It's my own fault: in my haste to get the question up before I fell asleep, I tried to simplify it, and couldn't work out from your first answer how to write the type predicate for the underlying function. – Richard Coates Jun 10 '22 at 17:44
  • 1
    Okay I'll do so when I get a chance. – jcalz Jun 10 '22 at 17:56

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360