-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Context
Currently, there's high coupling between typescript and typescript-operations plugins. Historically, these were designed to work together but since their roles and use cases slightly differ, it requires a lot of understanding of Codegen internals and how config options work together to use them right. Here's a TLDR; of these plugins:
typescript: the base plugin for both client and server use cases. As the result, types generated by this plugin is used by both typescript-operations and typescript-resolvers.- Options need to be configured differently, depending on which use case it is. For example, scalar needs types for input and output due to value coercion
typescript-operations: only used for client use cases.
Problems
Since typescript-operations is only used for client use cases, it does not need every type generated by the typescript plugin. However, most setup right now require using both plugins together, resulting unnecessarily large generated files. We tried to fix this issue previously but later reverted - because some users were using the generated schema types.
Using generated schema types for client use case is something we strongly recommend against. This has been a source of confusion for a long time. For example, schema types contain every field of an object type, and since we can fetch just the fields we need in clients in GraphQL, we only generate a subset of said object types for the expected result type. In these cases, by generating schema types, we have unintentionally created a foot-gun scenario where users use schema types, and they try to fetch every field of every object to satisfy TypeScript typecheck.
Guiding Principles
We want to fix the problems above with a few principles in mind:
- Must be easy for existing users to migrate to
- Must generate correct types, with minimal amount of code
- Must be simple to set up
These are all applicable to manual and Client Preset configs, in polyrepo or monorepo setup.
Proposal
Decouple typescript-opearations plugin from typescript plugin, and make it only generates the required types.
Currently, typescript-operations is only using a few things from typescript plugin:
- Any input types
- Scalar input types e.g.
Scalars['<Scalar name>']['input'] - Enum types
It should be straightforward to generate these - if they are used - either (1) inline with the variables and result type, or (2) in a separate file and imported into files with variable and result types.
- Option 1 is great for one generated type setup or Client Preset.
- Option 2 is better for monorepo, near operation file setup with runtime enum types such as const enums or native TS enums. In either scenario, generating the same enum and input types across multiple projects can be heavy, so a common approach is to generate one shared types file, and all cosumers can import from that.
Note that some generated utility functions in typescript plugins (such as InputMaybe and Scalars) would be transformed from generic to specific types, to avoid further coupling e.g.
Scalars['ID']['input']->string | numberInputMaybe<number>->number | null | undefined
Examples
Let's say we have the following schema:
type Query {
hero(id: ID!): Hero
heroes(input: HeroesInput!): [Hero!]!
}
type Hero {
id: ID!
name: String!
alignment: HeroAlignment!
}
enum HeroAlignment {
GOOD
NEUTRAL
EVIL
}
input HeroesInput {
name: String!
pagination: PaginationInput
}
input PaginationInput {
count: Int
offset: Int
}Option 1 - One file setup
// Client Preset
const config: CodegenConfig = {
// other options
generates: {
'./src/gql/': {
preset: ['client'],
},
},
};
// Manual setup
const config: CodegenConfig = {
// other options
generates: {
'./src/types.generated.ts': {
plugins: ['typescript-operations'],
},
},
};And if we have the following query:
query Heroes($input: HeroesInput!) {
heroes(input: $input) {
id
name
}
}Then, the generated file would look like this:
// types.generated.ts / graphql.ts (Client Preset)
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
type PaginationInput = {
offset?: number | null | undefined; // previously InputMaybe<Scalars['Int']['input']>
count?: number | null | undefined; // previously InputMaybe<Scalars['Int']['input']>
};
type HeroesInput = {
name: string; // Previously Scalars['String']['input']
pagination?: PaginationInput | null | undefined; // Previously InputMaybe<PaginationInput>;
}
export type HeroesQueryVariables = Exact<{
input: HeroesInput;
}>;
// ...the rest of result types are the sameNotes:
HeroAlignmentenum is not generated, because it's neither used by any input nor output. If it is used, it will be generated inline.InputMaybeandScalarsare not generated as they are concepts fromtypescriptpluginExactis generated here (previously generated bytypescriptplugin)
Option 2 - Multiple file setup
There will be an option in both operations plugin and Client Preset to refer to:
// Client Preset
const config: CodegenConfig = {
// other options
generates: {
'./src/types.generated.ts': {
plugins: ['typescript-operations'],
config: {
sharedTypesOnly: true,
}
}
'./src/gql/': {
preset: ['client'],
config: {
importSchemaTypesFrom: '../types.generated.ts'
}
},
},
};
// Manual setup
const config: CodegenConfig = {
// other options
generates: {
'./src/types.generated.ts': {
plugins: ['typescript-operations'],
config: {
sharedTypesOnly: true,
}
},
'./src/operations.generated.ts': { // can use near-operations-file setup or others
plugins: ['typescript-operations'],
config: {
importSchemaTypesFrom: './types.generated.ts'
}
},
},
};And if we have the following query:
query Hero(id: ID!) {
hero(id: $id) {
id
alignment
}
}Then, the generated shared file would look like this:
// types.generated.ts
export enum HeroAlignment {
Bad = 'BAD',
Good = 'GOOD',
Neutral = 'Neutral'
}And the operation generated file would look like this:
// operations.generated.ts / graphql.ts (Client Preset)
import type * as Types from './generated';
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type HeroQueryVariables = Exact<{
id: string
}>;
export type HeroQuery = {
__typename?: 'Query',
hero: {
__typename?: Hero,
id: string;
alignment: Types.HeroAlignment
}
}Notes:
sharedTypesOnly: truewill generate the input and enum types used in operation generated files will be generated in shared generated type fileimportSchemaTypesFromwill NOT generate input and enum types, instead it will import it from the shared type file.Exactis duplicated in every operation file since it's just utility type used locally, so there's no need to be shared.
Comparison against other libraries
Another library that generates client type is Relay. The above proposal has the same principles:
- Only generate input and enum types if they are used in an operation
- Types are inlined, instead of referencing using utility types (such as
InputMaybeandScalars)
Note: generated input and enum types are the same when comparing the proposal's vs Relay's. The main difference is where the types are generated: Relay generates one operation file per component, instead of one file (Option 1) or shared types (Option 2)
What's next?
These changes will be released in the next major version.
This is open to the community for suggestions and comments to help us. Please ask questions if anything is unclear, or if you have any use cases that will not work with these suggestions!

