Last active
April 19, 2022 08:30
-
-
Save thomasheyenbrock/96891c04751988689c5b7fc8deb767d3 to your computer and use it in GitHub Desktop.
Building type-safe queries with variables- and result-types in GraphQL
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable */ | |
/** | |
* What do we want to achieve here? 👀 | |
* | |
* 1. Write type-safe GraphQL queries with autocomplete and all the cool stuff | |
* 2. For the query we just wrote, we want to have... | |
* a. ...types for the resulting data | |
* b. ...types for the used variables | |
* | |
* In order for this to work at all, we assume that we have some kind of | |
* tool that generates a set of types for a given GraphQL schema. The catch | |
* is: I only wanna run this tool once, not re-run it every time I change | |
* something about my type-safe query from 1. | |
*/ | |
/** ======================================================================== */ | |
/** | |
* Let's assume we have the following schema. | |
*/ | |
const schema = /* GraphQL */ ` | |
type Query { | |
hello("Greeting someone with a name is a must-do" who: String!): Hello | |
} | |
type Hello { | |
goodbye( | |
"You can repeat the name when saying goodbye, but that's optional" | |
who: String | |
): String | |
} | |
` | |
/** ======================================================================== */ | |
/** | |
* And assume our tool generates us all the following beautiful types. (All of | |
* this can be derived from just the schema without knowing which queries | |
* will actually be sent!) | |
*/ | |
/** These are the types for the returned types of any field in the schema. */ | |
type HelloResult = { | |
goodbye: string | null | |
} | |
type QueryResult = { | |
say: string | null | |
hello: HelloResult | null | |
} | |
/** These are the types we'll use to write our type-safe queries. */ | |
type HelloSelectionSet = { | |
goodbye?: | |
| true /** Since all variables are optional, this is for convenience. */ | |
| [{ who?: string | null | Variable<string | null> }, true] | |
} | |
type QuerySelectionSet = { | |
hello?: [{ who: string | Variable<string> }, HelloSelectionSet] | |
} | |
type SelectionSet = QuerySelectionSet | HelloSelectionSet | |
/** We'll need a type to match variables... */ | |
type Variable<Type, Name extends string = string> = { | |
name: Name | |
/** We won't ever actually set this property 🤷 */ | |
type?: Type | |
} | |
/** ...and let's also add a tiny utility function to create variables. */ | |
function variable<Type, Name extends string>(name: Name): Variable<Type, Name> { | |
return { name } | |
} | |
/** This is basically what we're after. */ | |
type TypedDocumentNode< | |
Result extends Record<string, any> | null = Record<string, any> | null, | |
Variables extends Record<string, unknown> = Record<string, unknown>, | |
> = { | |
/** Getting a type for the result of the query... */ | |
__resultType: Result | |
/** ...and getting a type for all variables */ | |
__variablesType: Variables | |
} | |
/** ======================================================================== */ | |
/** | |
* So again, we wanna have a function with the following signature. The crux | |
* will be defining the generic mapping types `MapSelectionSetToResult` and | |
* `MapSelectionSetToVariables`. | |
*/ | |
function query<Sel extends QuerySelectionSet>( | |
selectionSet: Sel, | |
): TypedDocumentNode< | |
MapSelectionSetToResult<Sel>, | |
MapSelectionSetToVariables<Sel> | |
> { | |
/** I currently don't bother about the implementation, just about how to get | |
* proper type inference 😅 */ | |
return {} as any | |
} | |
/** If we get to that we can write the following type-safe query 🔓 */ | |
const exampleQuery = query({ | |
hello: [ | |
{ who: variable('who1') }, | |
{ goodbye: [{ who: variable('who2') }, true] }, | |
], | |
}) | |
/** We get a proper return type... */ | |
exampleQuery.__resultType.hello?.goodbye // type `string | null | undefined` | |
/** ...but more importantly we also get types for the variables we used 🤯 */ | |
exampleQuery.__variablesType.who1 // type `string` | |
exampleQuery.__variablesType.who2 // type `string | null` | |
/** ======================================================================== */ | |
/** | |
* Let the fun begin! 😄 (Don't judge me, I know this isn't perfect or complete | |
* or simple or beautiful or whatever 😅) | |
*/ | |
/** That's the easy one... */ | |
type MapSelectionSetToResult<Sel extends SelectionSet> = { | |
[K in keyof Sel]: K extends keyof QueryResult ? QueryResult[K] : never | |
} | |
/** Now for the more complicated one...we'll need to split that up. */ | |
type MapSelectionSetToVariables<Sel extends SelectionSet> = MapVariableToType< | |
FlattenVariables<ExtractVariablesFromSelectionSet<Sel>> | |
> | |
/** Let's move from the inside out. */ | |
/** | |
* First we move recursively though the selection set and find any selections | |
* where we pass variables. We'll store the types of these variables under a | |
* new key that matches the name we want to give to the variable. (That's the | |
* sole purpose of the second generic argument of the `Variable` type 💡) | |
* | |
* Example: | |
* | |
* type Sel = { | |
* hello: [ | |
* { who: Variable<string, 'who1'> }, | |
* { goodbye: [{ who: Variable<string | null, 'who2'> }, true] }, | |
* ] | |
* } | |
* type ExtractedVariables = ExtractVariablesFromSelectionSet<Sel> | |
* | |
* ExtractedVariables === { | |
* hello: { | |
* who1: Variable<string, 'who1'> | |
* goodbye: { | |
* who2: Variable<string | null, 'who2'> | |
* } | |
* } | |
* } | |
*/ | |
type ExtractVariablesFromSelectionSet<Sel extends SelectionSet> = { | |
[Field in keyof Sel as Sel[Field] extends | |
| SelectionSet | |
| [Record<string, unknown>, SelectionSet | true] | |
? Field | |
: never]: Sel[Field] extends SelectionSet | |
? ExtractVariablesFromSelectionSet<Sel[Field]> | |
: Sel[Field] extends [infer Vars, infer MaybeSubSelectionSet] | |
? Vars extends Record<string, unknown> | |
? { | |
/** | |
* Using key remapping just to make sure that we throw out keys that | |
* are not actually variables. | |
*/ | |
[ArgumentName in keyof Vars as Vars[ArgumentName] extends Variable< | |
any, | |
infer Name | |
> | |
? Name | |
: never]: Vars[ArgumentName] | |
} & (MaybeSubSelectionSet extends SelectionSet | |
? /** If there is a sub selection set, we recusrively map its variables. */ | |
ExtractVariablesFromSelectionSet<MaybeSubSelectionSet> | |
: /** Otherwise (i.e when selecting scalar values) */ {}) | |
: never | |
: never | |
} | |
/** | |
* Next, we need to flatten the nested object we just created. We'll again | |
* need some recursion. | |
* | |
* Example: | |
* | |
* type ExtractedVariables = { | |
* hello: { | |
* who1: Variable<string, 'who1'>, | |
* goodbye: { | |
* who2: Variable<string | null, 'who2'>, | |
* } | |
* } | |
* } | |
* type Flattened = FlattenVariables<ExtractedVariables> | |
* | |
* Flattened === { | |
* who1: Variable<string, 'who1'> | |
* who2: Variable<string | null, 'who2'> | |
* } | |
*/ | |
type FlattenVariables<Obj extends Record<string, unknown>> = Obj extends Record< | |
string, | |
Variable<any> | |
> | |
? // Object is already flat | |
Obj | |
: // Not yet flat, recursively try to flatten the object | |
AlreadyFlat<Obj> & | |
FlattenVariables< | |
Nested<Obj> extends Record<string, unknown> ? Nested<Obj> : never | |
> | |
/** | |
* Use key remapping again to extract only the keys that already have values | |
* of type `Variable` | |
*/ | |
type AlreadyFlat<Obj extends Record<string, unknown>> = { | |
[Key in keyof Obj as Obj[Key] extends Variable<any> ? Key : never]: Obj[Key] | |
} | |
/** | |
* Basically the inverse of the above, i.e. selecting the keys with values | |
* that are not yet of type `Variable`, but still an object that we can | |
* flatten recursively. | |
*/ | |
type NotYetFlat<Obj extends Record<string, unknown>> = { | |
[Key in keyof Obj as Obj[Key] extends Variable<any> | |
? never | |
: Obj[Key] extends Record<string, unknown> | |
? Key | |
: never]: Obj[Key] | |
} | |
/** Here the flattening actually happens 👀 */ | |
type Nested<Obj extends Record<string, unknown>> = | |
NotYetFlat<Obj>[keyof NotYetFlat<Obj>] | |
/** | |
* Now all that is left is to extract the generic type argument from the | |
* `Variable` type. After all you've seen, this is as easy as pie... | |
* | |
* Example: | |
* | |
* type Flattened = { | |
* who1: Variable<string, 'who1'> | |
* who2: Variable<string | null, 'who2'> | |
* } | |
* type VariableTypes = MapVariableToType<Flattened> | |
* | |
* VariableTypes === { | |
* who1: string | |
* who2: string | null | |
* } | |
*/ | |
type MapVariableToType<Vars> = { | |
[Name in keyof Vars as Vars[Name] extends Variable<infer Type> | |
? Type extends undefined | |
? never | |
: Name | |
: never]: Vars[Name] extends Variable<infer Type> ? Type : never | |
} | |
/** ======================================================================== */ | |
/** | |
* Some final notes: | |
* - I know there's `graphql-zeus` (the syntax of the `query` function argument | |
* is in fact inspired by it), but that doesn't quite get the job done. | |
* In particular I don't see how you can get types for variables. (The | |
* "documentation" sadly isn't that great imho 😕) | |
* - Does this provide a lot of value over something like "GraphQL Code | |
* Generator" or "Apollo GraphQL Codegen"? Not sure, but that's not why | |
* I went down this path. (It was more just for fun 😄) | |
* - This is a very basic example for a single query. No fragments, aliases, | |
* or other more advanced stuff. Though I believe that could be also | |
* solved when following though with this approach. | |
* | |
* And by the way, thanks if you actually read all of this! ❤️ | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment