From b3b13ed4490585c184b2c1c4f19a94b5e19afe5f Mon Sep 17 00:00:00 2001 From: ykiu <32252655+ykiu@users.noreply.github.com> Date: Sun, 16 Jul 2023 06:52:01 +0000 Subject: [PATCH] Introduce the Resolved utility type --- README.md | 101 ++++++++------- src/codegen.ts | 2 +- src/graphql.ts | 244 ++++++++++++++++++----------------- tests/graphql.test.ts | 75 ++++++++--- tests/readmeExamples.test.ts | 11 +- tests/schema.ts | 2 +- 6 files changed, 248 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index 448b30d..7c9b08a 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,21 @@ [![npm](https://img.shields.io/npm/v/gql-in-ts)](https://www.npmjs.com/package/gql-in-ts/) ![](https://github.com/ykiu/gql-in-ts/actions/workflows/ci.yaml/badge.svg) -A type-safe way to write GraphQL. Express your query as a plain object. Keep your code safe with the power of TypeScript. +A type-safe way to write GraphQL. Express your queries as plain objects and rely on the TypeScript compiler to keep your type definitions in sync with the queries. -A screen recording demonstrating what it looks like to write a query with gql-in-ts. +A screen recording demonstrating the usage of gql-in-ts. ## Features -**Straightforward** — Tired of maintaining a complex development environment with loads of plugins/extensions? `gql-in-ts` is a tiny library that comes with carefully-designed TypeScript type definitions. It requires no changes to your existing build process, yet it guarantees the correctness of your GraphQL queries with the help of the TypeScript compiler. +**Straightforward** — Tired of maintaining a complex development environment with loads of plugins/extensions? `gql-in-ts` is a tiny library that comes with carefully designed TypeScript type definitions. It requires no changes to your existing build process, yet it guarantees the correctness of your GraphQL queries with the help of the TypeScript compiler. -**Ergonomic** — Most existing GraphQL client solutions work by generating TypeScript code from GraphQL queries. `gql-in-ts`, in contrast, relies on TypeScript type inference to keep queries and types in sync, eliminating the need for code generation. +**Ergonomic** — Unlike most existing GraphQL client solutions that generate TypeScript code from GraphQL queries, `gql-in-ts` relies on TypeScript type inference to keep queries and types in sync, eliminating the need for code generation. **Portable** — Being agnostic of the runtime or view framework, `gql-in-ts` will Just Work™ in any ES5+ environment. ## Getting started -`gql-in-ts` supports TypeScript 4.4 thru 5.1. +Currently, `gql-in-ts` is tested against TypeScript versions 4.4 through 5.1. Install the library: @@ -32,8 +32,6 @@ Generate TypeScript code from your schema: npx gql-in-ts schema.graphql schema.ts ``` -Now you are all set! - ## Core concepts ### The `graphql` function @@ -58,9 +56,9 @@ const query = graphql('Query')({ }); ``` -The `graphql` function returns your query without doing any processing on it. However, its type signagures do enforce type checking, allowing TypeScript-compatible editors to provide instant feedback and auto-completion. +The `graphql` function returns the query object as is, without modifying it. However, its type signatures enforce type checking, allowing TypeScript-compatible editors to provide instant feedback and auto-completion. -You can split up a large query into smaller pieces, much like you do with GraphQL fragments: +A large query can be split into smaller pieces, similar to breaking down a function or a class into smaller functions or classes: ```ts import { graphql } from './schema'; @@ -79,14 +77,14 @@ const query = graphql('Query')({ }); ``` -### The `Result` type +### The `Resolved` utility type -Use the `Result` type for typing the response for the query: +The `Resolved` utility type can be used to define the response type of a query: ```ts -import { Result } from './schema'; +import { Resolved } from './schema'; -type QueryResult = Result; +type QueryResult = Resolved; // QueryResult would be inferred as: // { // user: { @@ -102,7 +100,7 @@ type QueryResult = Result; ### The `compileGraphQL` function -As mentioned earlier, the `graphql` function returns the given query unmodified. As your query is a plain JavaScript object, you'll need to convert it to a real GraphQL query before sending it to the server. Do so by using `compileGraphQL`: +As mentioned earlier, the `graphql` function returns the given query unmodified. Because the query is a plain JavaScript object at this point, it needs to be converted to a GraphQL string before being sent to the server. This can be achieved by using `compileGraphQL`: ```ts import { compileGraphQL } from './schema'; @@ -122,14 +120,18 @@ expect(compiled).toEqual( ); ``` -Think of `compileGraphQL` as a JSON.stringify() for GraphQL. +`compileGraphQL` is like `JSON.stringify()` for GraphQL. However, the return type of `compileGraphQL` is a string subtype named `GraphQLString`. `GraphQLString` is an ordinary string at runtime, but at the TypeScript level, it contains metadata for type inference. The `Resolved` utility can be used again to obtain the the type of the response: + +```ts +type MyResult = Resolved; +``` -While `compileGraphQL` returns an ordinary string at runtime, its return type in TypeScript is a string subtype named `GraphQLString`. `GraphQLString`, in addition to all the string properties, has one useful property: the `Result` for the compiled query. You can extract the `Result` by using [the infer keyword in a conditional type](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#inferring-within-conditional-types): +Alternatively, the resolved type can be extracted from the first type parameter of GraphQLString: ```ts import { GraphQLString } from './schema'; -type MyResult = typeof compiled extends GraphQLString ? TResult : never; +type MyResult = typeof compiled extends GraphQLString ? TResolved : never; ``` You can also pass your query directly to `compileGraphQL` instead of via `graphql`. @@ -145,14 +147,14 @@ const compiled = compileGraphQL('query')({ ### Making a request -`gql-in-ts` is agnostic of the transport layer: it's your responsibility to send requests to your backend server. That said, most GraphQL endpoints are [served over HTTP](https://graphql.org/learn/serving-over-http/), so here I include an example demonstrating how to send a typed GraphQL query using `fetch`: +`gql-in-ts` is agnostic of the transport layer, so it is your responsibility to send requests to your backend server. However, since most GraphQL endpoints are [served over HTTP](https://graphql.org/learn/serving-over-http/), here is an example demonstrating how to send a typed GraphQL query using `fetch`: ```ts import { GraphQLString } from './schema'; -const makeGraphQLRequest = async ( - compiled: GraphQLString, -): Promise => { +const makeGraphQLRequest = async ( + compiled: GraphQLString, +): Promise => { const response = await fetch('http://example.com/graphql', { method: 'POST', body: JSON.stringify({ query: compiled }), @@ -171,7 +173,7 @@ const makeGraphQLRequest = async ( ### Using aliases -You can alias a field by appending ` as [alias]` to the field name: +Fields can be aliased by appending ` as [alias]` to their names: ```ts import { graphql } from './schema'; @@ -183,11 +185,11 @@ const postFragment = graphql('Post')({ }); ``` -You can access the fields on the resulting response by their respective aliases. Aliasing is possible thanks to [template literal types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) of TypeScript. +The fields on the resulting response can be accessed by their respective aliases. ### Unions and interfaces -You can specify the type condition for a fragment by using keys with the pattern `... on [type name]`. Say `FeedItem` is an interface for things that appear in feeds, and `Post` and `Comment` implement `FeedItem`. `id` and `author` are defined in `FeedItem`, and additional fields are defined in the respective implementations: +Fields on unions and interfaces can be queried by using keys with the pattern `... on [type name]`. Say `FeedItem` is an interface for things that appear in feeds, and `Post` and `Comment` implement `FeedItem`. `id` and `author` are defined in `FeedItem`, and additional fields are defined in the respective implementations: ```ts import { graphql } from './schema'; @@ -207,12 +209,12 @@ const feedFragment = graphql('FeedItem')({ }); ``` -Use `__typename` to switch by the type of the feedItem to benefit from TypeScript's type narrowing feature: +On the response, `__typename` can be used to narrow down the type: ```ts -import { Result } from './schema'; +import { Resolved } from './schema'; -const processFeedItem = (feedItem: Result) => { +const processFeedItem = (feedItem: Resolved) => { if (feedItem.__typename === 'Comment') { // The type of feedItem is inferred as Comment in this block. } else if (feedItem.__typename === 'Post') { @@ -223,12 +225,12 @@ const processFeedItem = (feedItem: Result) => { ### Merging fragments -You can "merge" fragments. This is a powerful feature that allows to colocate fragments and the code that depends on them, maximizing maintainability of both the code and the query. +Fragments can be merged to form a larger fragment or a query. This comes in handy when you have small UI components that comprise a complex UI, where each component depends on different subsets of data from the GraphQL API. In such cases, it makes sense to use GraphQL fragments not just for fetching data, but also for describing the shape of inputs to the components. Suppose you want to render a post. You've split the rendering function into two parts where the first one is for the header of a post and the second one for the main text. The former is only interested in the post's `title` and `author`: ```ts -import { graphql, Result } from './schema'; +import { graphql, Resolved } from './schema'; const postHeaderFragment = graphql('Post')({ title: true, @@ -239,7 +241,7 @@ const postHeaderFragment = graphql('Post')({ }, }); -const renderPostHeader = (post: Result) => { +const renderPostHeader = (post: Resolved) => { // ... }; ``` @@ -247,13 +249,13 @@ const renderPostHeader = (post: Result) => { ...and the latter is only interested in the post's `content`: ```ts -import { graphql, Result } from './schema'; +import { graphql, Resolved } from './schema'; const postContentFragment = graphql('Post')({ content: true, }); -const renderPostContent = (post: Result) => { +const renderPostContent = (post: Resolved) => { // ... }; ``` @@ -261,7 +263,7 @@ const renderPostContent = (post: Result) => { Now on to the parent that renders both of them. Say the parent needs `id` as its own requirement. It also needs the data the children need so that it can pass that data to `renderPostHeader()` and `renderPostContent()`. You can write a fragment for the parent by merging the fragments of the children. Do so by using a special key spelled `...`: ```ts -import { graphql, Result } from './schema'; +import { graphql, Resolved } from './schema'; const postFragment = graphql('Post')({ id: true, @@ -269,7 +271,7 @@ const postFragment = graphql('Post')({ '... as b': postContentFragment. }); -const renderPost = (post: Result) => { +const renderPost = (post: Resolved) => { const postHeader = renderPostHeader(post); const postContent = renderPostHeader(post); // ... @@ -278,8 +280,6 @@ const renderPost = (post: Result) => { Note that two `...`s are given [aliases](#using-aliases) to avoid key collision. `...` is similar to the object spread syntax of JavaScript. However, by using `...` as a key, you are telling `gql-in-ts` to _recursively_ merge fragments, while the object spread syntax merges objects only _shallowly_. -This way, you can place GraphQL queries side-by-side with functions or classes that need data for the queries. The pattern is sometimes called colocation and is a good practice to keep your code DRY and maintainable. - _Caution: when you try to merge fragments with conflicting arguments, compileGraphQL will throw a runtime error. For example, the following is an error._ ```ts @@ -298,7 +298,7 @@ compileGraphQL('query')({ ### Using variables -Variables allow to compile a query once and to reuse it over and over again with different parameters. Compiling is not that expensive so you could write arguments inline, as in [the first example](#the-graphql-function), but for performance freaks, variables provide a way to tune their code. +Variables allow queries to be compiled once and to be reused with different parameters. Compiling is not expensive so you could write arguments inline, as in [the first example](#the-graphql-function), but a performance freak can tune their code by using variables. To define a fragment with variables, declare the names and the types of the variables, and pass a callback to `graphql`. You can reference the variables from within the callback: @@ -347,23 +347,36 @@ expect(compiled).toEqual( The syntax of variable definitions follows that of real GraphQL (e.g. types are optional by default, and types with "!" are required). Variable definitions are type-checked using [template literal types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html). -You can extract the types of the variables that a compiled query takes by using the second type parameter of `GraphQLString`: +The types of the variables that a compiled query takes can be extracted from the second type parameter of `GraphQLString`. Therefore, the `makeGraphQLRequest` function from the earlier section can be rewritten to take variables into account: ```ts import { GraphQLString } from './schema'; -type MyVariables = typeof compiled extends GraphQLString - ? TVariables - : never; +const makeGraphQLRequest = async ( + compiled: GraphQLString, + variables: TVariables, +): Promise => { + const response = await fetch('http://example.com/graphql', { + method: 'POST', + body: JSON.stringify({ query: compiled, variables }), + headers: { + 'content-type': 'application/json', + // If your endpoint requires authorization, comment out the code below. + // authorization: '...' + }, + }); + const responseData = (await response.json()).data; + return responseData; +}; ``` ## Limitations At the moment `gql-in-ts` has the following limitations: -- Not capable of eliminating extraneous fields. - - As it is hard to prevent objects from having extra properties in TypeScript, you won't get a type error even if you include a non-existent field in your query. Since GraphQL execution engines error when they meet an unknown field, this introduces an unsafeness where the code passes type check but errors at runtime. +- It cannot eliminate extraneous fields. + - As it is hard to prevent objects from having extra properties in TypeScript, you won't get a type error even if you include a non-existent field in your query. Since GraphQL execution engines throw an error when encountering an unknown field, this introduces a scenario where the code passes type checks but errors at runtime. ## Related works -Several other solutions employ an approach similar to this library. This one especially owes a lot to [GraphQL Zeus](https://github.com/graphql-editor/graphql-zeus) and [genql](https://github.com/remorses/genql) for the idea of using TypeScript type transformations to precisely type GraphQL response data. If you are interested in this project you may want to take a look at them as well. +Several other solutions employ an approach similar to this library. This project is particularly indebted to [GraphQL Zeus](https://github.com/graphql-editor/graphql-zeus) and [genql](https://github.com/remorses/genql) for their idea of using TypeScript type transformations to precisely type GraphQL response data. If you are interested in this project, you may want to take a look at them as well. diff --git a/src/codegen.ts b/src/codegen.ts index 479e920..0a579ec 100644 --- a/src/codegen.ts +++ b/src/codegen.ts @@ -255,7 +255,7 @@ const compileDocument = (schema: GraphQLSchema, params: CompileParams): string[] const footer = [ `export const graphql = makeGraphql();`, `export const compileGraphQL = makeCompileGraphQL();`, - `export type { Result, Selection, GraphQLString } from '${params.importPath}';`, + `export type { Resolved, Selection, GraphQLString } from '${params.importPath}';`, `export const defineVariables = makeDefineVariables();`, '', ]; diff --git a/src/graphql.ts b/src/graphql.ts index 35696fd..de6aae8 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -50,8 +50,8 @@ type OutputObjectTypeEntry = { type: OutputType; }; -type ObjectTypeNamespace = Record; -type InputTypeNamespace = Record; +type ObjectTypeMap = Record; +type InputTypeMap = Record; // ------------------------------ // Types that represent variables @@ -90,56 +90,54 @@ type VariableReference = - | NonNullableReference - | ListReference - | (keyof TInputTypeNamespace & string); +type VariableReferenceDefinition = + | NonNullableReference + | ListReference + | (keyof TInputTypeMap & string); type NonNullableReference< - TInputTypeNamespace extends InputTypeNamespace, - TDefinition extends VariableReferenceDefinition, + TInputTypeMap extends InputTypeMap, + TDefinition extends VariableReferenceDefinition, > = `${TDefinition}!`; type ListReference< - TInputTypeNamespace extends InputTypeNamespace, - TDefinition extends VariableReferenceDefinition, + TInputTypeMap extends InputTypeMap, + TDefinition extends VariableReferenceDefinition, > = `[${TDefinition}]`; type UnwrapNullable = T extends Nullable ? U : T; type VariableReferenceType< - TInputTypeNamespace extends InputTypeNamespace, - TDefinition extends VariableReferenceDefinition, -> = TDefinition extends keyof TInputTypeNamespace - ? Nullable - : TDefinition extends ListReference - ? Nullable>> - : TDefinition extends NonNullableReference - ? UnwrapNullable> + TInputTypeMap extends InputTypeMap, + TDefinition extends VariableReferenceDefinition, +> = TDefinition extends keyof TInputTypeMap + ? Nullable + : TDefinition extends ListReference + ? Nullable>> + : TDefinition extends NonNullableReference + ? UnwrapNullable> : never; export type VariableReferences< - TInputTypeNamespace extends InputTypeNamespace, - TVariableDefinitions extends VariableDefinitions, + TInputTypeMap extends InputTypeMap, + TVariableDefinitions extends VariableDefinitions, > = { [TKey in keyof TVariableDefinitions]: VariableReference< - VariableReferenceType + VariableReferenceType >; }; -type VariableDefinitions = Record< +type VariableDefinitions = Record< string, - VariableReferenceDefinition + VariableReferenceDefinition >; export type VariableReferenceValues< - TInputTypeNamespace extends InputTypeNamespace, - TVariableDefinitions extends VariableDefinitions, + TInputTypeMap extends InputTypeMap, + TVariableDefinitions extends VariableDefinitions, > = InputObjectTypeValue<{ [TKey in keyof TVariableDefinitions]: { - type: VariableReferenceType; + type: VariableReferenceType; }; }>; @@ -189,29 +187,30 @@ type SelectionType = TOutputType extends OutputO // ---------------------------------------- /** - * `Result` - * - * Infers the type of data that would be returned for `TSelection`. - * - * `Result` - * - * You can optionally pass in the second type parameter `TOutputObjectType` to explicitly - * specify which type in the schema `TSelection` is for. By default this parameter - * is automatically inferred so you rarely need to use the second parameter. + * Embeds the resolved type for a query/mutation/subscription. + */ +type HasResolved = { + __resolved?: (variables: TVariableDefinitions) => TResolved; +}; + +/** + * Extracts the resolved type for a query/mutation/subscription. */ -// This is a helper type for providing convenient features like automatic inference -// of TOutputObjectType and unwrapping of function-style selections for *external users*: -// internal types should rely on ResultForOutputObjectType for simplicity and for -// better compiler performance. -export type Result< - TSelection extends MaybeCallableSelection>, - TOutputObjectType extends OutputObjectType = never, -> = TSelection extends MaybeCallableSelection - ? TSelection extends HasOutputObjectType - ? ResultForNormalizedSelection> - : ResultForNormalizedSelection> +export type Resolved> = T extends HasResolved< + never, + infer TResult +> + ? TResult : never; +/** + * Resolves a query/mutation/subscription. + */ +export type Resolve< + TOutputObjectType extends OutputObjectType, + TSelection extends Selection, +> = ResolveNormalizedSelection>; + /** * Preprocesses selections to simplify subsequent processing. * @@ -221,14 +220,11 @@ export type Result< * 2. Recursively merges fragment spreads. * * @example - * type T1 = PreprocessSelection<{ a: true, '...': { b: true, '...': { c: true } } }>; + * type T1 = NormalizeSelection<{ a: true, '...': { b: true, '...': { c: true } } }>; * type T2 = { a: [{}, true], b: [{}, true], c: [{}, true] }; * // T1 == T2 */ -export type PreprocessSelection> = - NormalizeSelection; - -type NormalizeSelection> = MergeSpreads<{ +export type NormalizeSelection> = MergeSpreads<{ [TKey in keyof TSelection]: TSelection[TKey] extends SelectionEntryShape< infer TSelectionArgument, infer TSubSelection @@ -254,47 +250,33 @@ type NormalizeSelection> = MergeS TSelection[TKey]; }>; -/** A trivial helper for unwrapping a getter-style selection */ -type MaybeCallableSelection> = - | TSelection - | (($: never) => TSelection); - /** - * A trivial wrapper around ResultForOutputObjectType that accepts NormalizeSelection. + * Ensures NormalizedSelection is assignable to Selection. */ // NormalizeSelection is actually a Selection but TS cannot statically determine // it is, so use a conditional type to "dynamically" narrow down the type of the selection. -type ResultForNormalizedSelection< +type ResolveNormalizedSelection< TOutputObjectType extends OutputObjectType, TSelection, > = TSelection extends Selection - ? ResultForOutputObjectType extends infer T - ? { [K in keyof T]: T[K] } // Force TypeScript to evaluate the properties + ? ResolveSelection extends infer T + ? { [K in keyof T]: T[K] } // Bonus: for better hover hints, force TypeScript to evaluate the properties. : never - : { - [k in ERROR_FAILED_OBTAIN_TYPE_FROM_SELECTION]: TSelection; - }; - -type ERROR_FAILED_OBTAIN_TYPE_FROM_SELECTION = - 'The Result<> type failed to infer which type your query is for. This typically happens when you forgot to wrap your query with the graphql() function exported from your schema.ts. Either wrap your query like graphql("YourTypeName")({ /* your query */ }), or if you cannot do that for whatever reasons, explicitly specify the target type like Result'; - -type HasOutputObjectType = { - __type?: TOutputObjectType; -}; + : never; /** - * Core implementation of result type inference. + * Core implementation of type inference. */ // Iterates over the keys of TSelection and delegate handling of each property to the relevant type. // Also handles type condition of fragments. -type ResultForOutputObjectType< +type ResolveSelection< TOutputObjectType extends OutputObjectType, TSelection extends Selection, > = | ({ [TKey in keyof TSelection as TKey extends AliasKey ? TAlias // Transform "foo as bar" to "bar" - : TKey extends '__type' + : TKey extends '__resolved' ? never // Remove the key if it is "__type". The "__type" key is added by the graphql() function : // so that the Result type can determine the schema type of a query without explicit user input. TKey extends TypedFragmentKey @@ -319,7 +301,7 @@ type ResultForOutputObjectType< // To be able to use __typename as a tag for telling apart the union // constituents, __typename of union candidate 1 have to be narrowed // down to just 'B'. The below code makes it happen. - ResultEntry>, // This is the default result to start with. + ResolveSelectionEntry>, // This is the default result to start with. K extends TypedFragmentKey ? TOutputObjectType[K] extends { type: { __typename: { type: Predicate } }; @@ -330,9 +312,9 @@ type ResultForOutputObjectType< > : never // : TKey extends AliasKey - ? ResultEntry> // Selection with alias + ? ResolveSelectionEntry> // Selection with alias : TKey extends keyof TOutputObjectType - ? ResultEntry> // Selection without alias + ? ResolveSelectionEntry> // Selection without alias : never; } extends infer TResult // Let the above type TResult and do post processing on it. ? // Flatten type conditions. @@ -351,7 +333,7 @@ type ResultForOutputObjectType< // Handle each type condition. | (keyof TSelection extends infer TKey ? TKey extends TypedFragmentKey - ? ResultEntry> + ? ResolveSelectionEntry> : never : never); @@ -424,14 +406,14 @@ type UnionToIntersection = (T extends unknown ? (v: T) => void : never) exten ? U : never; -type ResultEntry< +type ResolveSelectionEntry< TOutputObjectTypeEntry extends OutputObjectTypeEntry, TSelectionEntry extends SelectionEntry | undefined, > = TSelectionEntry extends SelectionEntryShape - ? ResultType + ? ResolveSelectionType : never; -type ResultType< +type ResolveSelectionType< TInputType extends InputType, TSelectionType extends SelectionType, > = TInputType extends Predicate @@ -440,24 +422,22 @@ type ResultType< : never : TInputType extends OutputObjectType ? TSelectionType extends Selection - ? ResultForOutputObjectType extends infer T // Object + ? ResolveSelection extends infer T // Object ? { [K in keyof T]: T[K] } // Force TypeScript to evaluate the properties : never : never : TInputType extends List - ? ResultType[] // Array of another type + ? ResolveSelectionType[] // Array of another type : TInputType extends Nullable - ? ResultType | null // Nullable of another type + ? ResolveSelectionType | null // Nullable of another type : never; // ----------------------------------------------------------------- // Types for expressing compiled queries, mutations or subscriptions // ----------------------------------------------------------------- -export type GraphQLString = string & { - __result?: TResult; - __takeVariableValues?: (variableValues: TVariableValues) => void; -}; +export type GraphQLString = string & + HasResolved; // ----------------------------------------------------------- // Functions for compiling queries, mutations or subscriptions @@ -626,29 +606,29 @@ const compileSelectionEntry = , + TInputTypeMap extends InputTypeMap, + TDefinition extends VariableReferenceDefinition, >( definition: TDefinition, -): VariableReference> => ({ +): VariableReference> => ({ __definition: definition, }); const constructVariableReferences = < - TInputTypeNamespace extends InputTypeNamespace, - TVariableDefinitions extends VariableDefinitions, + TInputTypeMap extends InputTypeMap, + TVariableDefinitions extends VariableDefinitions, >( variables: TVariableDefinitions, ) => { const variableEntries = objectEntries(variables); const wrappedVariableEntries = variableEntries.map( - ([k, v]) => [k, constructVariableReference(v)] as const, + ([k, v]) => [k, constructVariableReference(v)] as const, ); const nameByVariable = new Map(wrappedVariableEntries.map(([k, v]) => [v, k])); return { variableObjects: objectFromEntries(wrappedVariableEntries) as VariableReferences< - TInputTypeNamespace, + TInputTypeMap, TVariableDefinitions >, variableNameByVariableObject: nameByVariable, @@ -656,24 +636,45 @@ const constructVariableReferences = < }; export const makeCompileGraphQL = < - TInputTypeNamespace extends InputTypeNamespace, + TInputTypeMap extends InputTypeMap, TSchema extends Schema, >() => { + // Overload 1: selection without variables + function fn( + type: TType, + ): >( + selection: TSelection, + ) => GraphQLString>; + + // Overload 2: selection with variables function fn< TType extends keyof TSchema & string, - TVariables extends VariableDefinitions, + TVariables extends VariableDefinitions, + >( + type: TType, + variables: TVariables, + ): >( + getSelection: ($: VariableReferences) => TSelection, + ) => GraphQLString< + Resolve, + VariableReferenceValues + >; + + function fn< + TType extends keyof TSchema & string, + TVariables extends VariableDefinitions, >(type: TType, variables?: TVariables) { return >( selection: | TSelection - | ((v: VariableReferences>) => TSelection), + | ((v: VariableReferences>) => TSelection), ): GraphQLString< - Result, - VariableReferenceValues + Resolve, + VariableReferenceValues > => { const cleanedVariables = (variables || {}) as NonNullable; const { variableObjects, variableNameByVariableObject } = constructVariableReferences< - TInputTypeNamespace, + TInputTypeMap, NonNullable >(cleanedVariables); const variableEntries = objectEntries(cleanedVariables); @@ -697,39 +698,50 @@ export const makeCompileGraphQL = < // --------------------------------------------------------- export const makeGraphql = < - TObjectTypeNamespace extends ObjectTypeNamespace, - TInputTypeNamespace extends InputTypeNamespace, + TObjectTypeMap extends ObjectTypeMap, + TInputTypeMap extends InputTypeMap, >() => { // Overload 1: selection without variables - function fn( + function fn( type: TTypeName, - ): >( + ): >( selection: TSelection, - ) => TSelection & HasOutputObjectType; + ) => TSelection & HasResolved>; // Overload 2: selection with variables function fn< - TTypeName extends keyof TObjectTypeNamespace, - TVariables extends VariableDefinitions, + TTypeName extends keyof TObjectTypeMap, + TVariables extends VariableDefinitions, >( type: TTypeName, variables: TVariables, ): < TGetSelection extends ( - v: VariableReferences, - ) => Selection, + v: VariableReferences, + ) => Selection, >( getSselection: TGetSelection, - ) => TGetSelection & HasOutputObjectType; + ) => TGetSelection & + HasResolved>>; // Actual implementation - function fn() { - return (selectionOrGetSelection: any) => selectionOrGetSelection; + function fn< + TType extends keyof TObjectTypeMap & string, + TVariables extends VariableDefinitions, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + >(type: TType, variables?: TVariables) { + return < + TSelection extends + | Selection + | ((v: VariableReferences) => Selection), + >( + selection: TSelection, + ) => selection; } return fn; }; export const makeDefineVariables = - () => - >(variables: TVariables) => + () => + >(variables: TVariables) => variables; diff --git a/tests/graphql.test.ts b/tests/graphql.test.ts index 5606e8f..9150da7 100644 --- a/tests/graphql.test.ts +++ b/tests/graphql.test.ts @@ -1,7 +1,14 @@ /* eslint-disable @typescript-eslint/no-namespace */ -import { describe, expect, it } from './vitest'; -import { LiteralOrVariable, PreprocessSelection, Selection } from '../src/graphql'; -import { Mutation, Query, graphql, compileGraphQL, GraphQLString, Result } from './schema'; +import { describe, expect, it, test } from './vitest'; +import { + LiteralOrVariable, + NormalizeSelection, + Resolve, + Resolved, + Selection, + VariableReferenceValues, +} from '../src/graphql'; +import { Mutation, Query, graphql, compileGraphQL, GraphQLString, InputTypeMap } from './schema'; // eslint-disable-next-line @typescript-eslint/no-unused-vars function expectType() { @@ -12,13 +19,38 @@ function expectType() { namespace To { export type BeAssignableTo = T; export type TakeGraphQLVariableValues = { - __takeVariableValues?: (values: TVariableValues) => void; + __resolved?: (values: TVariableValues) => void; }; export type TakeArguments = (...args: TArgs) => void; } type Tuple = { 0: A; 1: B }; +describe('VariableReferenceValues', () => { + test('Int', () => { + expectType< + { foo: number }, + To.BeAssignableTo> + >(); + expectType< + // @ts-expect-error: string is not assignable to Int!. + { foo: string }, + To.BeAssignableTo> + >(); + }); + test('InputObjectType', () => { + expectType< + { input: { username: string; password: string } }, + To.BeAssignableTo> + >(); + expectType< + // @ts-expect-error: password is missing. + { input: { username: string } }, + To.BeAssignableTo> + >(); + }); +}); + describe('Selection', () => { it('constrains a selection to match the schema', () => { expectType< @@ -59,7 +91,7 @@ describe('Result', () => { { title: true, content: [{ maxLength: 300 }, true], status: true }, ], }); - type Result1 = Result; + type Result1 = Resolved; expectType< Result1, To.BeAssignableTo<{ @@ -77,7 +109,7 @@ describe('Result', () => { }, ], })); - type Result1 = Result; + type Result1 = Resolved; expectType< Result1, To.BeAssignableTo<{ @@ -113,10 +145,10 @@ describe('Result', () => { }, }); - type PreprocessedQuery = PreprocessSelection; + type Normalized = NormalizeSelection>; expectType< - PreprocessedQuery, + Normalized, To.BeAssignableTo<{ user: Tuple< unknown, @@ -135,7 +167,7 @@ describe('Result', () => { }> >(); - type Result1 = Result; + type Result1 = Resolved; expectType< Result1, @@ -148,14 +180,16 @@ describe('Result', () => { }> >(); - type Result2 = Result<{ - __type: Query; - posts: { - title: true; - content: true; - }; - '... as ...1': Selection; - }>; + type Result2 = Resolve< + Query, + { + posts: { + title: true; + content: true; + }; + '... as ...1': Selection; + } + >; expectType< Result2, @@ -211,9 +245,10 @@ describe('Result', () => { }, }, }); - type PreprocessedQuery = PreprocessSelection; + + type Normalized = NormalizeSelection>; expectType< - PreprocessedQuery, + Normalized, To.BeAssignableTo<{ feed: Tuple< {}, @@ -246,7 +281,7 @@ describe('Result', () => { // Test type narrowing works as expected. // Note that the statements are wrapped in an immediately-GCed function so // that they can be tested without actually being executed. - (result: Result) => { + (result: Resolved) => { const feedItem = result.feed[0]; if (feedItem.__typename === 'Post') { expectType< diff --git a/tests/readmeExamples.test.ts b/tests/readmeExamples.test.ts index d8961c7..eef2c1d 100644 --- a/tests/readmeExamples.test.ts +++ b/tests/readmeExamples.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { expect, test } from './vitest'; -import { compileGraphQL, graphql, Result, GraphQLString } from './schema'; +import { compileGraphQL, graphql, Resolved, GraphQLString } from './schema'; declare const fetch: any; @@ -32,7 +32,7 @@ test('', () => { posts: [{ author: 'me' }, postFragment], }); - type QueryResult = Result; + type QueryResult = Resolved; // QueryResult would be inferred as: // { // user: { @@ -58,7 +58,8 @@ test('', () => { } }`, ); - type MyResult = typeof compiled__v1 extends GraphQLString ? TResult : never; + + type MyResult = Resolved; const compiled__v2 = compileGraphQL('query')({ user: userFragment, @@ -66,7 +67,7 @@ test('', () => { }); const makeGraphQLRequest = async ( - compiled: GraphQLString, + compiled: GraphQLString, ): Promise => { const response = await fetch('http://example.com/graphql', { method: 'POST', @@ -101,7 +102,7 @@ test('', () => { }, }); - const processFeedItem = (feedItem: Result) => { + const processFeedItem = (feedItem: Resolved) => { if (feedItem.__typename === 'Comment') { // The type of feedItem is Comment in this block. } else if (feedItem.__typename === 'Post') { diff --git a/tests/schema.ts b/tests/schema.ts index eb67116..90fe342 100644 --- a/tests/schema.ts +++ b/tests/schema.ts @@ -562,5 +562,5 @@ export type InputTypeMap = { export const graphql = makeGraphql(); export const compileGraphQL = makeCompileGraphQL(); -export type { Result, Selection, GraphQLString } from '../src'; +export type { Resolved, Selection, GraphQLString } from '../src'; export const defineVariables = makeDefineVariables();