diff --git a/src/Errors.ts b/src/Errors.ts index deda2d91..770756c0 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -280,6 +280,14 @@ export function ambiguousNumberType() { return `Unexpected number type. GraphQL supports both Int and Float, making \`number\` ambiguous. Instead, import the \`Int\` or \`Float\` type from \`${LIBRARY_IMPORT_NAME}\` and use that. e.g. \`import type { Int, Float } from "${LIBRARY_IMPORT_NAME}";\`.`; } +export function ambiguousNumberLiteralType() { + return `Unexpected numeric literal type. GraphQL supports both Int and Float. To ensure Grats infers the correct type, use the \`Int\` or \`Float\` type from \`${LIBRARY_IMPORT_NAME}\` instead. e.g. \`import type { Int, Float } from "${LIBRARY_IMPORT_NAME}";\`.`; +} + +export function literalTypeInInputPosition() { + return `Literal types like \`true\`, \`"hello"\`, or \`42\` cannot be used in GraphQL input positions (e.g., field arguments). GraphQL has no way to enforce that only this specific value is passed. Use the broader type (\`Boolean\`, \`String\`, \`Int\`, etc.) instead.`; +} + export function defaultValueIsNotLiteral() { return 'Expected GraphQL field argument default values to be a literal. Grats interprets argument defaults as GraphQL default values, which must be literals. For example: `10` or `"foo"`.'; } diff --git a/src/Extractor.ts b/src/Extractor.ts index 27182129..41f6b651 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -2622,6 +2622,22 @@ class Extractor { return this.gql.nonNullType(node, this.gql.namedType(node, "Boolean")); } else if (node.kind === ts.SyntaxKind.NumberKeyword) { return this.report(node, E.ambiguousNumberType()); + } else if (ts.isLiteralTypeNode(node)) { + // Literal types are only valid in output positions. In input positions, + // GraphQL cannot enforce that only this specific value is passed. + if (ctx.kind === "INPUT") { + return this.report(node, E.literalTypeInInputPosition()); + } + if ( + node.literal.kind === ts.SyntaxKind.TrueKeyword || + node.literal.kind === ts.SyntaxKind.FalseKeyword + ) { + return this.gql.nonNullType(node, this.gql.namedType(node, "Boolean")); + } else if (ts.isStringLiteral(node.literal)) { + return this.gql.nonNullType(node, this.gql.namedType(node, "String")); + } else if (ts.isNumericLiteral(node.literal)) { + return this.report(node, E.ambiguousNumberLiteralType()); + } } else if (ts.isTypeLiteralNode(node)) { return this.report(node, E.unsupportedTypeLiteral()); } else if (ts.isTypeOperatorNode(node)) { diff --git a/src/tests/fixtures/arguments/LiteralBooleanArgument.invalid.ts b/src/tests/fixtures/arguments/LiteralBooleanArgument.invalid.ts new file mode 100644 index 00000000..cc608b8b --- /dev/null +++ b/src/tests/fixtures/arguments/LiteralBooleanArgument.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(flag: true): string { + return "Hello world!"; + } +} diff --git a/src/tests/fixtures/arguments/LiteralBooleanArgument.invalid.ts.expected.md b/src/tests/fixtures/arguments/LiteralBooleanArgument.invalid.ts.expected.md new file mode 100644 index 00000000..c4529c40 --- /dev/null +++ b/src/tests/fixtures/arguments/LiteralBooleanArgument.invalid.ts.expected.md @@ -0,0 +1,24 @@ +# arguments/LiteralBooleanArgument.invalid.ts + +## Input + +```ts title="arguments/LiteralBooleanArgument.invalid.ts" +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(flag: true): string { + return "Hello world!"; + } +} +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/arguments/LiteralBooleanArgument.invalid.ts:4:15 - error: Literal types like `true`, `"hello"`, or `42` cannot be used in GraphQL input positions (e.g., field arguments). GraphQL has no way to enforce that only this specific value is passed. Use the broader type (`Boolean`, `String`, `Int`, etc.) instead. + +4 hello(flag: true): string { + ~~~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/arguments/LiteralFloatArgument.invalid.ts b/src/tests/fixtures/arguments/LiteralFloatArgument.invalid.ts new file mode 100644 index 00000000..7aa14869 --- /dev/null +++ b/src/tests/fixtures/arguments/LiteralFloatArgument.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(value: 3.14): string { + return "Hello world!"; + } +} diff --git a/src/tests/fixtures/arguments/LiteralFloatArgument.invalid.ts.expected.md b/src/tests/fixtures/arguments/LiteralFloatArgument.invalid.ts.expected.md new file mode 100644 index 00000000..d27c7d59 --- /dev/null +++ b/src/tests/fixtures/arguments/LiteralFloatArgument.invalid.ts.expected.md @@ -0,0 +1,24 @@ +# arguments/LiteralFloatArgument.invalid.ts + +## Input + +```ts title="arguments/LiteralFloatArgument.invalid.ts" +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(value: 3.14): string { + return "Hello world!"; + } +} +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/arguments/LiteralFloatArgument.invalid.ts:4:16 - error: Literal types like `true`, `"hello"`, or `42` cannot be used in GraphQL input positions (e.g., field arguments). GraphQL has no way to enforce that only this specific value is passed. Use the broader type (`Boolean`, `String`, `Int`, etc.) instead. + +4 hello(value: 3.14): string { + ~~~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/arguments/LiteralIntArgument.invalid.ts b/src/tests/fixtures/arguments/LiteralIntArgument.invalid.ts new file mode 100644 index 00000000..6c2541f6 --- /dev/null +++ b/src/tests/fixtures/arguments/LiteralIntArgument.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(count: 42): string { + return "Hello world!"; + } +} diff --git a/src/tests/fixtures/arguments/LiteralIntArgument.invalid.ts.expected.md b/src/tests/fixtures/arguments/LiteralIntArgument.invalid.ts.expected.md new file mode 100644 index 00000000..10d71bc5 --- /dev/null +++ b/src/tests/fixtures/arguments/LiteralIntArgument.invalid.ts.expected.md @@ -0,0 +1,24 @@ +# arguments/LiteralIntArgument.invalid.ts + +## Input + +```ts title="arguments/LiteralIntArgument.invalid.ts" +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(count: 42): string { + return "Hello world!"; + } +} +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/arguments/LiteralIntArgument.invalid.ts:4:16 - error: Literal types like `true`, `"hello"`, or `42` cannot be used in GraphQL input positions (e.g., field arguments). GraphQL has no way to enforce that only this specific value is passed. Use the broader type (`Boolean`, `String`, `Int`, etc.) instead. + +4 hello(count: 42): string { + ~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/arguments/LiteralStringArgument.invalid.ts b/src/tests/fixtures/arguments/LiteralStringArgument.invalid.ts new file mode 100644 index 00000000..091e74ba --- /dev/null +++ b/src/tests/fixtures/arguments/LiteralStringArgument.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(greeting: "hello"): string { + return "Hello world!"; + } +} diff --git a/src/tests/fixtures/arguments/LiteralStringArgument.invalid.ts.expected.md b/src/tests/fixtures/arguments/LiteralStringArgument.invalid.ts.expected.md new file mode 100644 index 00000000..ee6c09da --- /dev/null +++ b/src/tests/fixtures/arguments/LiteralStringArgument.invalid.ts.expected.md @@ -0,0 +1,24 @@ +# arguments/LiteralStringArgument.invalid.ts + +## Input + +```ts title="arguments/LiteralStringArgument.invalid.ts" +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(greeting: "hello"): string { + return "Hello world!"; + } +} +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/arguments/LiteralStringArgument.invalid.ts:4:19 - error: Literal types like `true`, `"hello"`, or `42` cannot be used in GraphQL input positions (e.g., field arguments). GraphQL has no way to enforce that only this specific value is passed. Use the broader type (`Boolean`, `String`, `Int`, etc.) instead. + +4 hello(greeting: "hello"): string { + ~~~~~~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/field_definitions/LiteralBooleanField.ts b/src/tests/fixtures/field_definitions/LiteralBooleanField.ts new file mode 100644 index 00000000..7de6d770 --- /dev/null +++ b/src/tests/fixtures/field_definitions/LiteralBooleanField.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(): true { + return true; + } +} diff --git a/src/tests/fixtures/field_definitions/LiteralBooleanField.ts.expected.md b/src/tests/fixtures/field_definitions/LiteralBooleanField.ts.expected.md new file mode 100644 index 00000000..7f22e284 --- /dev/null +++ b/src/tests/fixtures/field_definitions/LiteralBooleanField.ts.expected.md @@ -0,0 +1,45 @@ +# field_definitions/LiteralBooleanField.ts + +## Input + +```ts title="field_definitions/LiteralBooleanField.ts" +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(): true { + return true; + } +} +``` + +## Output + +### SDL + +```graphql +type SomeType { + hello: Boolean +} +``` + +### TypeScript + +```ts +import { GraphQLSchema, GraphQLObjectType, GraphQLBoolean } from "graphql"; +export function getSchema(): GraphQLSchema { + const SomeTypeType: GraphQLObjectType = new GraphQLObjectType({ + name: "SomeType", + fields() { + return { + hello: { + name: "hello", + type: GraphQLBoolean + } + }; + } + }); + return new GraphQLSchema({ + types: [SomeTypeType] + }); +} +``` \ No newline at end of file diff --git a/src/tests/fixtures/field_definitions/LiteralNumberField.invalid.ts b/src/tests/fixtures/field_definitions/LiteralNumberField.invalid.ts new file mode 100644 index 00000000..547f8591 --- /dev/null +++ b/src/tests/fixtures/field_definitions/LiteralNumberField.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(): 42 { + return 42; + } +} diff --git a/src/tests/fixtures/field_definitions/LiteralNumberField.invalid.ts.expected.md b/src/tests/fixtures/field_definitions/LiteralNumberField.invalid.ts.expected.md new file mode 100644 index 00000000..4743699e --- /dev/null +++ b/src/tests/fixtures/field_definitions/LiteralNumberField.invalid.ts.expected.md @@ -0,0 +1,24 @@ +# field_definitions/LiteralNumberField.invalid.ts + +## Input + +```ts title="field_definitions/LiteralNumberField.invalid.ts" +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(): 42 { + return 42; + } +} +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/field_definitions/LiteralNumberField.invalid.ts:4:12 - error: Unexpected numeric literal type. GraphQL supports both Int and Float. To ensure Grats infers the correct type, use the `Int` or `Float` type from `grats` instead. e.g. `import type { Int, Float } from "grats";`. + +4 hello(): 42 { + ~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/field_definitions/LiteralStringField.ts b/src/tests/fixtures/field_definitions/LiteralStringField.ts new file mode 100644 index 00000000..d3c351c3 --- /dev/null +++ b/src/tests/fixtures/field_definitions/LiteralStringField.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(): "hello" { + return "hello"; + } +} diff --git a/src/tests/fixtures/field_definitions/LiteralStringField.ts.expected.md b/src/tests/fixtures/field_definitions/LiteralStringField.ts.expected.md new file mode 100644 index 00000000..b8dfd03e --- /dev/null +++ b/src/tests/fixtures/field_definitions/LiteralStringField.ts.expected.md @@ -0,0 +1,45 @@ +# field_definitions/LiteralStringField.ts + +## Input + +```ts title="field_definitions/LiteralStringField.ts" +/** @gqlType */ +export default class SomeType { + /** @gqlField */ + hello(): "hello" { + return "hello"; + } +} +``` + +## Output + +### SDL + +```graphql +type SomeType { + hello: String +} +``` + +### TypeScript + +```ts +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const SomeTypeType: GraphQLObjectType = new GraphQLObjectType({ + name: "SomeType", + fields() { + return { + hello: { + name: "hello", + type: GraphQLString + } + }; + } + }); + return new GraphQLSchema({ + types: [SomeTypeType] + }); +} +``` \ No newline at end of file diff --git a/src/tests/fixtures/input_types/InputTypeWithLiteralField.invalid.ts b/src/tests/fixtures/input_types/InputTypeWithLiteralField.invalid.ts new file mode 100644 index 00000000..bfd238ff --- /dev/null +++ b/src/tests/fixtures/input_types/InputTypeWithLiteralField.invalid.ts @@ -0,0 +1,4 @@ +/** @gqlInput */ +type MyInput = { + flag: true; +}; diff --git a/src/tests/fixtures/input_types/InputTypeWithLiteralField.invalid.ts.expected.md b/src/tests/fixtures/input_types/InputTypeWithLiteralField.invalid.ts.expected.md new file mode 100644 index 00000000..bb54534b --- /dev/null +++ b/src/tests/fixtures/input_types/InputTypeWithLiteralField.invalid.ts.expected.md @@ -0,0 +1,21 @@ +# input_types/InputTypeWithLiteralField.invalid.ts + +## Input + +```ts title="input_types/InputTypeWithLiteralField.invalid.ts" +/** @gqlInput */ +type MyInput = { + flag: true; +}; +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/input_types/InputTypeWithLiteralField.invalid.ts:3:9 - error: Literal types like `true`, `"hello"`, or `42` cannot be used in GraphQL input positions (e.g., field arguments). GraphQL has no way to enforce that only this specific value is passed. Use the broader type (`Boolean`, `String`, `Int`, etc.) instead. + +3 flag: true; + ~~~~ +``` \ No newline at end of file diff --git a/website/docs/07-changelog/index.md b/website/docs/07-changelog/index.md index 80427bab..ddadab3c 100644 --- a/website/docs/07-changelog/index.md +++ b/website/docs/07-changelog/index.md @@ -5,6 +5,7 @@ - **Features** - Added support for async derived context functions. Derived context functions can now return `Promise` and Grats will automatically generate the necessary `await` expressions in resolver code. - Added support for `readonly T[]` as parsable GraphQL types. + - Added support for literal `boolean` and `string` types in output positions. A field returning `true` will be typed as `Boolean`, and `"hello"` as `String`. Changes in this section are not yet released. If you need access to these changes before we cut a release, check out our `@main` NPM releases. Each commit on the main branch is [published to NPM](https://www.npmjs.com/package/grats?activeTab=versions) under the `main` tag.