Skip to content

Instantly share code, notes, and snippets.

@thomasheyenbrock
Last active April 19, 2022 08:30
Show Gist options
  • Save thomasheyenbrock/96891c04751988689c5b7fc8deb767d3 to your computer and use it in GitHub Desktop.
Save thomasheyenbrock/96891c04751988689c5b7fc8deb767d3 to your computer and use it in GitHub Desktop.
Building type-safe queries with variables- and result-types in GraphQL
/* 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