Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I write general TypeGuard for GraphQL unions?

Tags:

typescript

I'd like to write one-size-fits-all Type Guard for GraphQL unions.

Let's say we have two types A and B, and C is the union type of them. A has a property a but B doesn't. I think it can be useful if we could have a type guard like this:

xs.filter(isTypename("a")).map(x => x.a)

I tried a while but I still didn't complete.

Is it able to be achieved?

https://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgILIN4ChnIPp5gCeADhCHALYQBcyARHPQNw7Jx0DOYUoA5qwC+WUJFiIUAIUxsCxMhWp16AIxZsVXHvyFYs8lAGFkAXjTIAPskmt9pKXE4ozGYVggAPEgHsoYZAjeINzIwJwAKvaKzsgAPOHInpAgACacyNy8IHwANMgAct4pEAlJ5GmYclFUtBna2YIAfAAUbAbRdOFYAJSmjcjNIEW1hcXh3XRDxaHpAPKUwGCxoyV59FUKNfT9AGSY+ITVSsgJgn0yuFAQYACuUCDIUxAAdBvkNaYmZu01QraBwX8Hk4dEMAG0ALqmZBgjAHH7HRj0PIcBhMZCCPJwt4dBhqPKaPH0DEQ-5BEJETgARmhwOeMGAABsxM0wpFNtRmkjut1npQ4CRmh5zh5nnBuqwgA

interface A {
  __typename: "a";
  a: string;
}
interface B {
  __typename: "b";
  b: string;
}

type C = A | B;

type Base = {}

export const isTypename = <T extends string, NodeT extends {__typename: string}>(
  typename: T
) => (node: NodeT): node is Omit<NodeT, "__typename"> & { __typename: T } => {
  return node.__typename === typename;
};

const xs: C[] = [{ __typename: "a", a: "a" }, { __typename: "b", b: "b" }];

const ys1 = xs.filter(isTypename("a")).map(x => x.a);

like image 383
mtsmfm Avatar asked Sep 16 '25 15:09

mtsmfm


1 Answers

I think if you expect to be using discriminated union types, then you will get better behavior if your user-defined type guard returns a type like val is Extract<DiscriminatedUnionType, {discriminant: LiteralDiscriminant}> instead of intersections like val is DiscriminatedUnionType & {discriminant: LiteralDiscriminant} (or the one with Omit<DiscriminatedUnionType , "discriminant"> & {discriminant: LiteralDiscriminant}, which won't even compile because the compiler can't verify that it's a subtype of DiscriminatedUnionType).

UPDATE FOR TS3.9+: the above issue with intersections has been improved greatly in TS3.9. Now, intersections are reduced by discriminant properties (see microsoft/TypeScript#36696), so val is DiscriminatedUnionType & {discriminant: LiteralDiscriminant} will now eliminate incompatible types from the discriminated union. I still recommend using Extract instead, though, because the output is simpler and doesn't carry around the intersection. END UPDATE

Extract<T, U> is a utility type that uses a distributive conditional type to filter unions. Hypothetically, intersections could be made to do the same thing, but in TypeScript intersections of disjoint object types do not reduce to never. So while {foo: "a", a: string} & {foo: "b"} is equivalent to never (you'll never get a value of that type), the compiler does not actually reduce it to never. On the other hand, Extract<{foo: "a", a: string}, {foo: "b"}> will produce never. And you need that reduction, since X | never reduces to X.


So let's try the following version of the type guard:

const isTypename = <T extends string>(typename: T) => <
  N extends { __typename: string }
>(
  node: N
): node is Extract<N, { __typename: T }> => {
  return node.__typename === typename;
};

in which the return type predicate uses Extract.

Note that I also moved the generic N (your NodeT) to the signature of the returned function. That should help with type inference. The call isTypeName("a") by itself has no obvious inference site for N. If I did

const filterCb = isTypename("a"); 
xs.filter(filterCb); 

Your version would end up having N inferred as { __typename: string } instead of as C, and unfortunately the return type would be node is never instead of node is A, which would be a problem.

Sometimes contextual typing can save you, so

xs.filter(isTypename("a"));

might be enough contextual type to infer N as C and everything would be great. But generally I'd like isTypeName("a") to return a generic function in which N is definitely not going to be prematurely resolved.


Okay, let's see if it works:

const ys1 = xs.filter(isTypename("a")).map(x => x.a); // string[]

Looks good! Hope that helps. Good luck.

Link to code

like image 109
jcalz Avatar answered Sep 18 '25 09:09

jcalz