Describe the bug
Nested unions with fragments and nested unions produce incorrect types. The union type is correct one level deep, but is incorrect two levels deep.
To Reproduce
https://codesandbox.io/s/graphql-codegen-issue-nested-unions-hc90i
Check test.ts for an example of where we should be able to access keys, but typescript only lets us access __typename
schema {
query: Query
}
type Query {
animal: Animal!
}
type Animal {
type: AnimalType!
}
union AnimalType = Dog | Cat
union Age = Known | Unknown
type Known {
years: Int!
months: Int!
}
type Unknown {
reason: String!
}
type Dog {
age: Age!
}
type Cat {
age: Age!
}
query GetAnimal {
animal {
type {
... on Dog {
__typename
age {
... on Known {
__typename
years
}
}
}
...DogAge
}
}
}
fragment DogAge on AnimalType {
... on Dog {
__typename
age {
... on Known {
__typename
months
}
}
}
}
codegen.yml config file:schema: schema.graphql
documents: document.graphql
generates:
types.ts:
plugins:
- typescript
- typescript-operations
Expected behavior
Check codesandbox
Environment:
"@graphql-codegen/typescript": "1.8.3",
"@graphql-codegen/typescript-operations": "1.8.3",
Additional context
I believe the issue comes from the way the query and the fragment are merged. Typescript merges the unions by computing all possible pairs. However, the union elements should really be merged based on __typename.
type QueryPart = {
__typename: "Dog";
// age: A | B
age: { __typename: "Known"; years: number } | { __typename: "Unknown" };
};
type FragmentPart = {
__typename: "Dog";
// age: C | D
age: { __typename: "Known"; months: number } | { __typename: "Unknown" };
};
// QueryPart & FragmentPart === Union
type Union = {
__typename: "Dog";
// Intersecting the types results in age: A & C | A & B | B & C | B & D
// age should really be: A & C | B & D
age:
| ({ __typename: "Known"; months: number } & {
__typename: "Known";
months: number;
})
| ({ __typename: "Known"; years: number } & { __typename: "Unknown" })
| ({ __typename: "Unknown" } & { __typename: "Known"; months: number })
| ({ __typename: "Unknown" } & { __typename: "Unknown" });
}
I'm not very familiar with the internals of graphql-code-generator, so there may be an easier way to approach this, but I took a stab at fixing this using a generic. The generic below fixes this particular case, but I don't think it's very robust (would not work with 3 level deep union). Figured it could be a useful place to start though.
// IsUnion returns true/false if type is a union
type UnionToIntersection<U> = (U extends any
? (k: U) => void
: never) extends (k: infer I) => void
? I
: never;
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;
// Given Union and Typename, return the type from the union which __typename
// matches the given Typename
type MatchingTypename<Union, Typename> = Union extends { __typename: Typename }
? Union
: never;
// Given two unions, U1 and U2, merges each type in U1 with the type in U2
// that has a matching __typename
type MergeOnTypename<U1, U2> = U1 extends { __typename: string }
? U1 & MatchingTypename<U2, U1["__typename"]>
: U1 & U2;
// Given two types A and B, if any of the keys have union values, it will
// merge them based on their __typename
type ApplyFragment<A, B> = {
[k in keyof A & keyof B]: IsUnion<A[k]> extends true
? MergeOnTypename<A[k], B[k]>
: A[k] & B[k];
};
Example:
type QueryPart = {
__typename: "Dog";
age: { __typename: "Known"; years: number } | { __typename: "Unknown" };
};
type FragmentPart = {
__typename: "Dog";
age: { __typename: "Known"; months: number } | { __typename: "Unknown" };
};
// graphql-code-generator currently does this
type Original = QueryPart & FragmentPart;
// What we want
type Expected = {
__typename: "Dog";
age:
| { __typename: "Known"; months: number; years: number }
| { __typename: "Unknown" };
};
// Using ApplyFragment
type Fixed = ApplyFragment<QueryPart, FragmentPart>;
let original: Original = {};
if (original.age.__typename === "Known") {
// Error accessing properties
original.age.months;
original.age.years;
}
let expected: Expected = {};
if (expected.age.__typename === "Known") {
// Can access properties
expected.age.months;
expected.age.years;
}
let fixed: Fixed = {};
if (fixed.age.__typename === "Known") {
// Can access properties
fixed.age.months;
fixed.age.years;
}
Thanks @rynobax .
Just verified it, a reproduction could be found here.
@rynobax I'll try your suggested solution. The actual issue here is that types are not being merged on the nested level with just &.
@n1ru4l what do you think?
Are there plans to fix this? It's blocking us from upgrading to the latest version :(
@agonbina we are working on that, but it requires some more testing.
PRs are always welcome ;)
TypeScript 3.8 appears to have fixed this in the playground example.
@threehams is right, this was fixed since TS 3.8 :)