Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pink-drinks-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-codegen/typescript-operations': minor
---

Add support for semanticNonNull.errorHandlingClient
6 changes: 5 additions & 1 deletion packages/plugins/typescript/operations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
"tslib": "~2.6.0"
},
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
"graphql-sock": "^1.0.0"
},
"devDependencies": {
"graphql-sock": "1.0.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
Expand Down
38 changes: 38 additions & 0 deletions packages/plugins/typescript/operations/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,4 +292,42 @@ export interface TypeScriptDocumentsPluginConfig extends RawDocumentsConfig {
*/

allowUndefinedQueryVariables?: boolean;

/**
* @description Options related to `@semanticNonNull` directive
* @exampleMarkdown
* ## `errorHandlingClient`
* When using error handling clients, a semantic non-nullable field can never be `null`.
* If a field is read and its value is `null`, there must be a respective error. The error handling client will throw in this case, so the `null` value is never read.
*
* To enable this option, install `graphql-sock` peer dependency:
*
* ```sh npm2yarn
* npm install -D graphql-sock
* ```
*
* Now, you can enable support for error handling clients:
*
* ```ts filename="codegen.ts"
* import type { CodegenConfig } from '@graphql-codegen/cli';
*
* const config: CodegenConfig = {
* // ...
* generates: {
* 'path/to/file.ts': {
* plugins: ['typescript', 'typescript-operations'],
* config: {
* semanticNonNull: {
* errorHandlingClient: true
* }
* },
* },
* },
* };
* export default config;
* ```
*/
semanticNonNull?: {
Copy link
Copy Markdown
Contributor

@benjie benjie Mar 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps, and this is just a suggestion, rename this to "semantic nullability" as a more generic term. If we were to accept a solution that used a document directive to enable the syntax, this is the likely name that we would use for that directive, and it doesn't tie the solution to one specific implementation. (GraphQL-SOCK can be updated to work with other solutions without any effort on your part.)

Suggested change
semanticNonNull?: {
semanticNullability?: {

errorHandlingClient: boolean;
};
Copy link
Copy Markdown
Collaborator Author

@eddeee888 eddeee888 Mar 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking this object can be extended to handle other related semanticNonNull functionalities e.g. @catch?
(I'm not entirely sure how it'd work yet, but I imagine there could be options we turn on/off here 😅 )

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see @catch as mainly orthogonal to @semanticNonNull. Sure they make a lot of sense together but you can have an error handling client that uses @catch(to: RESULT) or @catch(to: THROW) without semantic nullability on the server.

That client will not get the semantic nullability information but could still use @catch to modify the shape of the generated code.

I would keep it simple/yagni with just a plain Boolean option:

semanticNonNull?: boolean

If really you want to bundle nullability options together, maybe something like so:

nullability?: {
  semanticNonNull: boolean;
  catch: boolean;
}

}
19 changes: 16 additions & 3 deletions packages/plugins/typescript/operations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { TypeScriptDocumentsVisitor } from './visitor.js';

export { TypeScriptDocumentsPluginConfig } from './config.js';

export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.ComplexPluginOutput> = (
export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.ComplexPluginOutput> = async (
schema: GraphQLSchema,
rawDocuments: Types.DocumentFile[],
config: TypeScriptDocumentsPluginConfig
) => {
const transformedSchema = config.semanticNonNull?.errorHandlingClient ? await semanticToStrict(schema) : schema;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally would rename the schema variable to inputSchema so that schema, the variable you'd use by default, is the correct variable to use:

Suggested change
schema: GraphQLSchema,
rawDocuments: Types.DocumentFile[],
config: TypeScriptDocumentsPluginConfig
) => {
const transformedSchema = config.semanticNonNull?.errorHandlingClient ? await semanticToStrict(schema) : schema;
inputSchema: GraphQLSchema,
rawDocuments: Types.DocumentFile[],
config: TypeScriptDocumentsPluginConfig
) => {
const schema = config.semanticNonNull?.errorHandlingClient ? await semanticToStrict(inputSchema) : inputSchema;

(This would also mean no other changes were needed in the function.)


const documents = config.flattenGeneratedTypes
? optimizeOperations(schema, rawDocuments, {
? optimizeOperations(transformedSchema, rawDocuments, {
includeFragments: config.flattenGeneratedTypesIncludeFragments,
})
: rawDocuments;
Expand All @@ -30,7 +32,7 @@ export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.Compl
...(config.externalFragments || []),
];

const visitor = new TypeScriptDocumentsVisitor(schema, config, allFragments);
const visitor = new TypeScriptDocumentsVisitor(transformedSchema, config, allFragments);

const visitorResult = oldVisit(allAst, {
leave: visitor,
Expand Down Expand Up @@ -64,3 +66,14 @@ export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.Compl
};

export { TypeScriptDocumentsVisitor };

const semanticToStrict = async (schema: GraphQLSchema): Promise<GraphQLSchema> => {
try {
const sock = await import('graphql-sock');
return sock.semanticToStrict(schema);
} catch {
throw new Error(
"To use the `customDirective.semanticNonNull` option, you must install the 'graphql-sock' package."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"To use the `customDirective.semanticNonNull` option, you must install the 'graphql-sock' package."
"To use the `semanticNonNull.errorHandlingClient` option, you must install the 'graphql-sock' package."

);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { buildSchema, parse } from 'graphql';
import * as prettier from 'prettier';
import { plugin } from '../src/index.js';

const schema = buildSchema(/* GraphQL */ `
directive @semanticNonNull(levels: [Int] = [0]) on FIELD_DEFINITION

type Query {
me: User
}

type User {
field: String @semanticNonNull
fieldLevel0: String @semanticNonNull(levels: [0])
fieldLevel1: String @semanticNonNull(levels: [1])
fieldBothLevels: String @semanticNonNull(levels: [0, 1])
list: [String] @semanticNonNull
listLevel0: [String] @semanticNonNull(levels: [0])
listLevel1: [String] @semanticNonNull(levels: [1])
listBothLevels: [String] @semanticNonNull(levels: [0, 1])
nonNullableList: [String]! @semanticNonNull
nonNullableListLevel0: [String]! @semanticNonNull(levels: [0])
nonNullableListLevel1: [String]! @semanticNonNull(levels: [1])
nonNullableListBothLevels: [String]! @semanticNonNull(levels: [0, 1])
listWithNonNullableItem: [String!] @semanticNonNull
listWithNonNullableItemLevel0: [String!] @semanticNonNull(levels: [0])
listWithNonNullableItemLevel1: [String!] @semanticNonNull(levels: [1])
listWithNonNullableItemBothLevels: [String!] @semanticNonNull(levels: [0, 1])
nonNullableListWithNonNullableItem: [String!]! @semanticNonNull
nonNullableListWithNonNullableItemLevel0: [String!]! @semanticNonNull(levels: [0])
nonNullableListWithNonNullableItemLevel1: [String!]! @semanticNonNull(levels: [1])
nonNullableListWithNonNullableItemBothLevels: [String!]! @semanticNonNull(levels: [0, 1])
}
`);

const document = parse(/* GraphQL */ `
query {
me {
field
fieldLevel0
fieldLevel1
fieldBothLevels
list
listLevel0
listLevel1
listBothLevels
nonNullableList
nonNullableListLevel0
nonNullableListLevel1
nonNullableListBothLevels
listWithNonNullableItem
listWithNonNullableItemLevel0
listWithNonNullableItemLevel1
listWithNonNullableItemBothLevels
nonNullableListWithNonNullableItem
nonNullableListWithNonNullableItemLevel0
nonNullableListWithNonNullableItemLevel1
nonNullableListWithNonNullableItemBothLevels
}
}
`);

describe('TypeScript Operations Plugin - semanticNonNull', () => {
it('converts semanticNonNull to nonNull when semanticNonNull.errorHandlingClient=true', async () => {
const result = await plugin(schema, [{ document }], {
semanticNonNull: {
errorHandlingClient: true,
},
});

const formattedContent = prettier.format(result.content);
expect(formattedContent).toMatchInlineSnapshot(`
"export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never }>;

export type Unnamed_1_Query = {
__typename?: "Query",
me?: {
__typename?: "User",
field: string,
fieldLevel0: string,
fieldLevel1?: string | null,
fieldBothLevels: string,
list: Array<string | null>,
listLevel0: Array<string | null>,
listLevel1?: Array<string> | null,
listBothLevels: Array<string>,
nonNullableList: Array<string | null>,
nonNullableListLevel0: Array<string | null>,
nonNullableListLevel1: Array<string>,
nonNullableListBothLevels: Array<string>,
listWithNonNullableItem: Array<string>,
listWithNonNullableItemLevel0: Array<string>,
listWithNonNullableItemLevel1?: Array<string> | null,
listWithNonNullableItemBothLevels: Array<string>,
nonNullableListWithNonNullableItem: Array<string>,
nonNullableListWithNonNullableItemLevel0: Array<string>,
nonNullableListWithNonNullableItemLevel1: Array<string>,
nonNullableListWithNonNullableItemBothLevels: Array<string>,
} | null,
};
"
`);
});

it('does not convert semanticNonNull to nonNull when semanticNonNull.errorHandlingClient=false', async () => {
const result = await plugin(schema, [{ document }], {
semanticNonNull: {
errorHandlingClient: false,
},
});

const formattedContent = prettier.format(result.content);
expect(formattedContent).toMatchInlineSnapshot(`
"export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never }>;

export type Unnamed_1_Query = {
__typename?: "Query",
me?: {
__typename?: "User",
field?: string | null,
fieldLevel0?: string | null,
fieldLevel1?: string | null,
fieldBothLevels?: string | null,
list?: Array<string | null> | null,
listLevel0?: Array<string | null> | null,
listLevel1?: Array<string | null> | null,
listBothLevels?: Array<string | null> | null,
nonNullableList: Array<string | null>,
nonNullableListLevel0: Array<string | null>,
nonNullableListLevel1: Array<string | null>,
nonNullableListBothLevels: Array<string | null>,
listWithNonNullableItem?: Array<string> | null,
listWithNonNullableItemLevel0?: Array<string> | null,
listWithNonNullableItemLevel1?: Array<string> | null,
listWithNonNullableItemBothLevels?: Array<string> | null,
nonNullableListWithNonNullableItem: Array<string>,
nonNullableListWithNonNullableItemLevel0: Array<string>,
nonNullableListWithNonNullableItemLevel1: Array<string>,
nonNullableListWithNonNullableItemBothLevels: Array<string>,
} | null,
};
"
`);
});
});
Loading