typescriptpayload-cms

Is it possible to write a typeguard for an arbitrary amount of generic types?


In payload CMS, polymorphic relationship many fields have a type like the following:

interface TypeA { a: 1 }
interface TypeB { b: 2 }
interface TypeC { c: 3 }

type Example = {
  items:
    | (
      | {
          relationTo: 'type-a';
          value: number | TypeA;
        }
      | {
          relationTo: 'type-b';
          value: number | TypeB;
        }
      | {
          relationTo: 'type-c';
          value: number | TypeC;
        }
      /* ... any number of these types ... */
    )[]
}

Numbers may be returned from the API if permission to read the value is denied, or if the request has hit the maximum depth allowed.

I am struggling to write a reusable/generic type guard that filters out the number from the value fields. Ideally, I would like a function which, given any polymorphic many relationship field returns the same array, but with the number items filtered and the updated types (meaning value but without number).

A solution like this doesn't work, because TypeScript expects all of TypeA, TypeB, TypeC, etc to be the same generic type T.

function numberGuard<T>(
  item: {
    relationTo: string,
    value: number | T
  }
): item is {
  relationTo: string,
  value: T
} {
  return typeof item.value !== "number"
}

function polyRelationshipMany<T>(
  items: {
    relationTo: string,
    value: number | T
  }[]
) {
  return items.filter(numberGuard);
}

declare const e: Example
const z = polyRelationshipMany(e.items)[0] // error!
// Note the error:
/*
Argument of type '({ relationTo: "type-a"; value: number | TypeA; } | { relationTo: "type-b"; value: number | TypeB; } | { relationTo: "type-c"; value: number | TypeC; })[]' is not assignable to parameter of type '{ relationTo: string; value: number | TypeA; }[]'.
  Type '{ relationTo: "type-a"; value: number | TypeA; } | { relationTo: "type-b"; value: number | TypeB; } | { relationTo: "type-c"; value: number | TypeC; }' is not assignable to type '{ relationTo: string; value: number | TypeA; }'.
    Type '{ relationTo: "type-b"; value: number | TypeB; }' is not assignable to type '{ relationTo: string; value: number | TypeA; }'.
      Types of property 'value' are incompatible.
        Type 'number | TypeB' is not assignable to type 'number | TypeA'.
          Type 'TypeB' is not assignable to type 'number | TypeA'.
*/


Typescript Playground Example


Solution

  • One problem you're having is that TypeScript does not synthesize union types during inference of generic type arguments. When you call a function of the form function f<T>(arg: A<T>): R<T>, TypeScript will accept arguments of type A<X> for any X that's already part of the type. But if you pass in an argument of a union type like A<X> | A<Y>, TypeScript will usually not attempt to unify X and Y into a single type argument X | Y. Instead it decides that the call was probably a mistake and issues a warning. This behavior is intentional and described in microsoft/TypeScript#23312 and Why isn't the type argument inferred as a union type?.

    Of course nothing stops you from explicitly specifying the type argument, like this:

    const arr = polyRelationshipMany<TypeA | TypeB | TypeC>(e.items); // okay
    /* const arr: {
      relationTo: string;
      value: TypeA | TypeB | TypeC;
    }[] */
    

    That works, but... the output type isn't really what you want, is it? The resulting array has no idea that relationTo can only be one of the known string literal types, or that those string literal types are correlated with the value. Instead TypeA, TypeB, and TypeC have been squished together. That means the elements of your array are no longer discriminated unions:

    e.items.map(x => {
      if (typeof x.value === "number") return 0;
      switch (x.relationTo) {
        case "type-a": return x.value.a; // okay
        case "type-b": return x.value.b; // okay
        case "type-c": return x.value.c; // okay
      }
    });
    
    const arr = polyRelationshipMany<TypeA | TypeB | TypeC>(e.items);
    arr.map(x => {
      switch (x.relationTo) {
        case "type-a": return x.value.a; // error!
        case "type-b": return x.value.b; // error!
        case "type-c": return x.value.c; // error!
      }
    });
    

    Ideally you want arr's element type to still be a discriminated union, where each of the union members' value property has number excluded from it. This is possible, but it's more complicated to write. TypeScript won't automatically distribute generic function calls over union types in its function arguments, as requested in microsoft/TypeScript#52295, so you'll have to explicitly anticipate this and write for it.


    Here's one approach:

    function numberGuard<T extends { value: unknown }>(v: T):
      v is ExcludeNumberFromValue<T> {
      return typeof v.value !== "number"
    }
    function polyRelationshipMany<T extends { value: unknown }>(
      items: T[]
    ): ExcludeNumberFromValue<T>[] {
      return items.filter(numberGuard);
    }
    

    where ExcludeNumberFromValue<T> is what we have to write. A first attempt could be

    type ExcludeNumberFromValue<T extends { value: unknown }> =
        Omit<T, "value"> & { value: Exclude<T["value"], number> };
    

    using the Omit and Exclude utility types. That does something reasonable for an input type like

    type X = ExcludeNumberFromValue<{ x: Date, value: string | number }>
    // type X = Omit<{ x: Date, value: string | number }, "value"> & { value: string }
    

    well, it's hard to see what type that is, so we can use something like How can I see the full expanded contract of a Typescript type? to show us:

    type IdX = Id<X>;
    // type IdX = { x: Date; value: string; }
    

    Hooray! Unfortunately, this does not distribute over unions in T:

    type Y = Id<ExcludeNumberFromValue<
      { x: Date, value: string | number } | 
      { y: number, value: boolean | number }
    >>
    // type Y = { value: string | boolean; }
    

    We lost x and y entirely, and squished value together. That's just the nature of Omit (see Typescript: Omit a property from all interfaces in a union, but keep the union structure ). We need a technique to operate over each union member separately. The go-to approach here is to make it a distributive conditional type. Wrapping it with T extends unknown ? ⋯ : never will do it:

    type ExcludeNumberFromValue<T extends { value: unknown }> =
      T extends unknown ? 
        Omit<T, "value"> & { value: Exclude<T["value"], number> } 
      : never;
    

    Now we get the right type for Y:

    /* type Y = {
      x: Date;
      value: string;
    } | {
      y: number;
      value: boolean;
    } */
    

    Once we use a conditional type like this it's a little bit easier to use infer within conditional types to pull out the value property type, instead of using an indexed access type:

    type ExcludeNumberFromValue<T extends { value: unknown }> =
      T extends { value: infer V } ?
        Omit<T, "value"> & { value: Exclude<V, number> } 
     : never;
    

    But that's the same thing.


    So that type is what we want, mostly... except, we can't directly use it:

    declare function numberGuard<T extends { value: unknown; }>(
      v: T): v is ExcludeNumberFromValue<T> // error!
    // A type predicate's type must be assignable to its parameter's type.   
    

    Oops, TypeScript can't tell that ExcludeNumberFromValue<T> is a valid narrowing of T. See Stricter narrowing in TypeScript that is only allowed if the narrowed case is possible. We need to force that to happen, possibly just by intersecting with T:

    type ExcludeNumberFromValue<T extends { value: unknown }> =
      (T extends { value: infer V } ?
        Omit<T, "value"> & { value: Exclude<V, number> } : never) & T;
    
    declare function numberGuard<T extends { value: unknown; }>(
      v: T): v is ExcludeNumberFromValue<T> // okay
    

    but while conceptually that's right, intersection tends to have some weird effects:

    /* type Y = {
      x: Date;
      value: string;
    } | {
      x: Date;
      value: never;
      y: number;
    } | {
      y: number;
      value: boolean;
    }*/
    

    That {value: never} is weird. In this case I think we can use Extract instead:

    type ExcludeNumberFromValue<T extends { value: unknown }> =
      Extract<T extends { value: infer V } ?
        Omit<T, "value"> & { value: Exclude<V, number> } : never, T>;
    

    That works, and Y looks correct... if you use the Id<T> utility type. We could leave it there, but then your output type might be a bit unpleasant to look at:

    const arr = polyRelationshipMany(e.items);
    /* const arr: ((Omit<{
        relationTo: "type-a";
        value: number | TypeA;
    }, "value"> & {
        value: TypeA;
    }) | (Omit<{
        relationTo: "type-b";
        value: number | TypeB;
    }, "value"> & {
        value: TypeB;
    }) | (Omit<...> & {
        ...;
    }))[];
    

    So let's build in that Id<T> utility type in there. It looks like

    type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
    

    which is really just splitting a union into members, doing an "identity" mapped type over each member, and pushing the union together again. We want to put that inline, and it should be inside the Extract since we want the whole thing to be assignable to T. Now we have:

    type ExcludeNumberFromValue<T extends { value: unknown }> =
      Extract<(T extends { value: infer V } ?
        Omit<T, "value"> & { value: Exclude<V, number> } : never
      ) extends infer U ? { [K in keyof U]: U[K] } : never, T>;
    

    and

    const arr = polyRelationshipMany(e.items);
    /* const arr: (
      { relationTo: "type-a"; value: TypeA; } |
      { relationTo: "type-b"; value: TypeB; } | 
      { relationTo: "type-c"; value: TypeC; }
    )[] */
    
    arr.map(x => {
      switch (x.relationTo) {
        case "type-a": return x.value.a; // okay
        case "type-b": return x.value.b; // okay
        case "type-c": return x.value.c; // okay
      }
    })
    

    That looks like what we wanted. The type of arr's elements looks like Example with all the number parts of value filtered out without affecting the rest of the shape. And thus we can treat each element like the discriminated union it is.

    Playground link to code