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);
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With