diff --git a/src/Errors.ts b/src/Errors.ts index 770756c0..06a2b653 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -236,7 +236,7 @@ export function argNotTyped() { } export function enumTagOnInvalidNode() { - return `Expected \`@${ENUM_TAG}\` to be a union type, or a string literal in the edge case of a single value enum. For example: \`type MyEnum = "foo" | "bar"\` or \`type MyEnum = "foo"\`.`; + return `Expected \`@${ENUM_TAG}\` to be a union type, a string literal in the edge case of a single value enum, or a const array member type query. For example: \`type MyEnum = "foo" | "bar"\`, \`type MyEnum = "foo"\`, or \`const VALUES = ["foo", "bar"] as const; type MyEnum = typeof VALUES[number]\`.`; } export function enumVariantNotStringLiteral() { @@ -247,6 +247,14 @@ export function enumVariantMissingInitializer() { return `Expected \`@${ENUM_TAG}\` enum members to have string literal initializers. For example: \`FOO = 'foo'\`. In GraphQL enum values are strings, and Grats needs to be able to see the concrete value of the enum member to generate the GraphQL schema.`; } +export function enumTypeAliasConstArrayEmpty() { + return `Expected \`@${ENUM_TAG}\` const array to include at least one string literal value. For example: \`const VALUES = ["foo"] as const; type MyEnum = typeof VALUES[number]\`.`; +} + +export function enumVariantDuplicateValue(value: string) { + return `Expected \`@${ENUM_TAG}\` enum members to be unique. Found duplicate value \`${value}\`.`; +} + export function gqlEntityMissingName() { return "Expected GraphQL entity to have a name. Grats uses the name of the entity to derive the name of the GraphQL construct."; } diff --git a/src/Extractor.ts b/src/Extractor.ts index 41f6b651..5aad86ba 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -2170,6 +2170,10 @@ class Extractor { ]; } + if (ts.isIndexedAccessTypeNode(node.type)) { + return this.enumTypeAliasConstArrayVariants(node, node.type); + } + if (!ts.isUnionTypeNode(node.type)) { this.reportUnhandled(node.type, "union", E.enumTagOnInvalidNode()); return null; @@ -2207,6 +2211,153 @@ class Extractor { return values; } + enumTypeAliasConstArrayVariants( + node: ts.TypeAliasDeclaration, + indexedAccessType: ts.IndexedAccessTypeNode, + ): EnumValueDefinitionNode[] | null { + let objectType: ts.TypeNode = indexedAccessType.objectType; + while (ts.isParenthesizedTypeNode(objectType)) { + objectType = objectType.type; + } + + if ( + !ts.isTypeQueryNode(objectType) || + !ts.isIdentifier(objectType.exprName) || + indexedAccessType.indexType.kind !== ts.SyntaxKind.NumberKeyword + ) { + this.reportUnhandled( + indexedAccessType, + "union", + E.enumTagOnInvalidNode(), + ); + return null; + } + + const declaration = this.findTopLevelConstDeclaration( + node.getSourceFile(), + objectType.exprName.text, + ); + if (declaration == null) { + this.reportUnhandled( + indexedAccessType, + "union", + E.enumTagOnInvalidNode(), + ); + return null; + } + + const arrayLiteral = this.constAssertionArrayExpression(declaration); + if (arrayLiteral == null) { + this.reportUnhandled( + indexedAccessType, + "union", + E.enumTagOnInvalidNode(), + ); + return null; + } + + if (arrayLiteral.elements.length === 0) { + this.report(arrayLiteral, E.enumTypeAliasConstArrayEmpty()); + return null; + } + + const values: EnumValueDefinitionNode[] = []; + const seenValues = new Map(); + for (const element of arrayLiteral.elements) { + if (!ts.isStringLiteral(element)) { + this.reportUnhandled( + element, + "union member", + E.enumVariantNotStringLiteral(), + ); + continue; + } + + const duplicateValueNode = seenValues.get(element.text); + if (duplicateValueNode != null) { + this.report(element, E.enumVariantDuplicateValue(element.text), [ + tsRelated( + duplicateValueNode, + "Previous enum member with this value.", + ), + ]); + continue; + } + + seenValues.set(element.text, element); + + const errorMessage = graphQLNameValidationMessage(element.text); + if (errorMessage != null) { + this.report(element, errorMessage); + } + + values.push( + this.gql.enumValueDefinition( + node, + this.gql.name(element, element.text), + undefined, + null, + null, + ), + ); + } + + return values; + } + + findTopLevelConstDeclaration( + sourceFile: ts.SourceFile, + name: string, + ): ts.VariableDeclaration | null { + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) { + continue; + } + + if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) { + continue; + } + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) { + continue; + } + + if (declaration.name.text === name) { + return declaration; + } + } + } + + return null; + } + + constAssertionArrayExpression( + declaration: ts.VariableDeclaration, + ): ts.ArrayLiteralExpression | null { + if (declaration.initializer == null) { + return null; + } + + if (!ts.isAsExpression(declaration.initializer)) { + return null; + } + + if ( + !ts.isTypeReferenceNode(declaration.initializer.type) || + !ts.isIdentifier(declaration.initializer.type.typeName) || + declaration.initializer.type.typeName.text !== "const" + ) { + return null; + } + + if (!ts.isArrayLiteralExpression(declaration.initializer.expression)) { + return null; + } + + return declaration.initializer.expression; + } + collectEnumValues( node: ts.EnumDeclaration, ): ReadonlyArray { diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQuery.ts b/src/tests/fixtures/enums/EnumFromConstArrayTypeQuery.ts new file mode 100644 index 00000000..76a71950 --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQuery.ts @@ -0,0 +1,15 @@ +const ALL_SHOW_STATUSES = [ + "draft", + "scheduled", + "unlisted", + "published", +] as const; + +/** @gqlType */ +class Show { + /** @gqlField */ + status: ShowStatus; +} + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQuery.ts.expected.md b/src/tests/fixtures/enums/EnumFromConstArrayTypeQuery.ts.expected.md new file mode 100644 index 00000000..5ffb8237 --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQuery.ts.expected.md @@ -0,0 +1,77 @@ +# enums/EnumFromConstArrayTypeQuery.ts + +## Input + +```ts title="enums/EnumFromConstArrayTypeQuery.ts" +const ALL_SHOW_STATUSES = [ + "draft", + "scheduled", + "unlisted", + "published", +] as const; + +/** @gqlType */ +class Show { + /** @gqlField */ + status: ShowStatus; +} + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; +``` + +## Output + +### SDL + +```graphql +enum ShowStatus { + draft + published + scheduled + unlisted +} + +type Show { + status: ShowStatus +} +``` + +### TypeScript + +```ts +import { GraphQLSchema, GraphQLEnumType, GraphQLObjectType } from "graphql"; +export function getSchema(): GraphQLSchema { + const ShowStatusType: GraphQLEnumType = new GraphQLEnumType({ + name: "ShowStatus", + values: { + draft: { + value: "draft" + }, + published: { + value: "published" + }, + scheduled: { + value: "scheduled" + }, + unlisted: { + value: "unlisted" + } + } + }); + const ShowType: GraphQLObjectType = new GraphQLObjectType({ + name: "Show", + fields() { + return { + status: { + name: "status", + type: ShowStatusType + } + }; + } + }); + return new GraphQLSchema({ + types: [ShowStatusType, ShowType] + }); +} +``` \ No newline at end of file diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts new file mode 100644 index 00000000..de41433c --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts @@ -0,0 +1,4 @@ +const ALL_SHOW_STATUSES = ["draft", "draft"] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts.expected.md b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts.expected.md new file mode 100644 index 00000000..85a8b655 --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts.expected.md @@ -0,0 +1,26 @@ +# enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts + +## Input + +```ts title="enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts" +const ALL_SHOW_STATUSES = ["draft", "draft"] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts:1:37 - error: Expected `@gqlEnum` enum members to be unique. Found duplicate value `draft`. + +1 const ALL_SHOW_STATUSES = ["draft", "draft"] as const; + ~~~~~~~ + + src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts:1:28 + 1 const ALL_SHOW_STATUSES = ["draft", "draft"] as const; + ~~~~~~~ + Previous enum member with this value. +``` \ No newline at end of file diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts new file mode 100644 index 00000000..859d5723 --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts @@ -0,0 +1,4 @@ +const ALL_SHOW_STATUSES = [] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts.expected.md b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts.expected.md new file mode 100644 index 00000000..aeec5994 --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts.expected.md @@ -0,0 +1,21 @@ +# enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts + +## Input + +```ts title="enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts" +const ALL_SHOW_STATUSES = [] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts:1:27 - error: Expected `@gqlEnum` const array to include at least one string literal value. For example: `const VALUES = ["foo"] as const; type MyEnum = typeof VALUES[number]`. + +1 const ALL_SHOW_STATUSES = [] as const; + ~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts new file mode 100644 index 00000000..9cb5f81c --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts @@ -0,0 +1,4 @@ +const ALL_SHOW_STATUSES = ["draft-status"] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts.expected.md b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts.expected.md new file mode 100644 index 00000000..e639d3a6 --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts.expected.md @@ -0,0 +1,21 @@ +# enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts + +## Input + +```ts title="enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts" +const ALL_SHOW_STATUSES = ["draft-status"] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts:1:28 - error: Names must only contain [_a-zA-Z0-9] but "draft-status" does not. + +1 const ALL_SHOW_STATUSES = ["draft-status"] as const; + ~~~~~~~~~~~~~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts new file mode 100644 index 00000000..9b138a43 --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts @@ -0,0 +1,4 @@ +const ALL_SHOW_STATUSES = ["draft", 42] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts.expected.md b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts.expected.md new file mode 100644 index 00000000..76c84618 --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts.expected.md @@ -0,0 +1,23 @@ +# enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts + +## Input + +```ts title="enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts" +const ALL_SHOW_STATUSES = ["draft", 42] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithNonStringValue.invalid.ts:1:37 - error: Expected `@gqlEnum` enum members to be string literal types. For example: `'foo'`. Grats needs to be able to see the concrete value of the enum member to generate the GraphQL schema. + +If you think Grats should be able to infer this union member, please report an issue at https://github.com/captbaritone/grats/issues. + +1 const ALL_SHOW_STATUSES = ["draft", 42] as const; + ~~ +``` \ No newline at end of file diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts new file mode 100644 index 00000000..62e32a9f --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts @@ -0,0 +1,4 @@ +const ALL_SHOW_STATUSES = ["draft", "scheduled", "unlisted", "published"]; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; diff --git a/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts.expected.md b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts.expected.md new file mode 100644 index 00000000..df6158ae --- /dev/null +++ b/src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts.expected.md @@ -0,0 +1,23 @@ +# enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts + +## Input + +```ts title="enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts" +const ALL_SHOW_STATUSES = ["draft", "scheduled", "unlisted", "published"]; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; +``` + +## Output + +### Error Report + +```text +src/tests/fixtures/enums/EnumFromConstArrayTypeQueryWithoutAsConst.invalid.ts:4:19 - error: Expected `@gqlEnum` to be a union type, a string literal in the edge case of a single value enum, or a const array member type query. For example: `type MyEnum = "foo" | "bar"`, `type MyEnum = "foo"`, or `const VALUES = ["foo", "bar"] as const; type MyEnum = typeof VALUES[number]`. + +If you think Grats should be able to infer this union, please report an issue at https://github.com/captbaritone/grats/issues. + +4 type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` \ No newline at end of file diff --git a/website/docs/03-resolvers/03-descriptions.mdx b/website/docs/03-resolvers/03-descriptions.mdx index a3ddaa5a..1babcb6a 100644 --- a/website/docs/03-resolvers/03-descriptions.mdx +++ b/website/docs/03-resolvers/03-descriptions.mdx @@ -35,6 +35,10 @@ In some cases, TypeScript does not support "attaching" docblock comments to cert For example, Grats is **not** currently able to attach descriptions to enum values defined using a type union. +The same limitation applies to const-array-backed enum aliases (`type MyEnum = +typeof VALUES[number]`). In both forms, Grats can read the enum values, but +TypeScript does not provide attachable enum-value docblock metadata. + Will _incorrectly_ extract this GraphQL: diff --git a/website/docs/04-docblock-tags/07-enums.mdx b/website/docs/04-docblock-tags/07-enums.mdx index 32895060..8e1b2bb3 100644 --- a/website/docs/04-docblock-tags/07-enums.mdx +++ b/website/docs/04-docblock-tags/07-enums.mdx @@ -4,6 +4,7 @@ GraphQL enums can be defined by placing a `@gqlEnum` docblock directly before a: - TypeScript enum declaration - Type alias of a union of string literals +- Type alias of a const-array member type query (for example `typeof VALUES[number]`) ```ts /** @@ -46,3 +47,45 @@ This is due to the fact that TypeScript does not see JSDoc comments as /** @gqlEnum */ type MyEnum = "OK" | "ERROR"; ``` + +We also support defining enums from a const array using a type query. This is +useful when you want one source of truth for both runtime code and type-level +checks. For example, you can use the const array at runtime to check whether a +user-supplied list covers all enum values: + +```ts +const ALL_SHOW_STATUSES = ["DRAFT", "SCHEDULED", "PUBLISHED"] as const; + +/** @gqlEnum */ +type ShowStatus = (typeof ALL_SHOW_STATUSES)[number]; + +/** @gqlInput */ +type ShowStatusFilter = { anyOf: ShowStatus[] }; + +function applyFilter(filter: ShowStatusFilter) { + // Because ALL_SHOW_STATUSES is a real array, we can compare at runtime + if (filter.anyOf.length === ALL_SHOW_STATUSES.length) { + // No filtering needed — every status was selected + return getAllShows(); + } + return getShowsByStatus(filter.anyOf); +} +``` + +This form is intentionally strict right now. Grats expects all of the following: + +- `ALL_SHOW_STATUSES` must be a local top-level `const` +- The initializer must be a direct `as const` array literal +- The array must be non-empty +- Every value must be a unique string literal +- Every value must be a valid GraphQL enum name + +Because this is still a type-alias enum form, it has the same tradeoffs as +string-literal unions: + +- You cannot add descriptions to enum values +- You cannot mark enum values as deprecated + +Type-alias enums (including this form) are also not supported when +`tsClientEnums` is enabled. In that case, use an exported TypeScript +`enum` declaration instead. diff --git a/website/docs/06-faq/01-limitations.md b/website/docs/06-faq/01-limitations.md index 0e187a92..514ffbc9 100644 --- a/website/docs/06-faq/01-limitations.md +++ b/website/docs/06-faq/01-limitations.md @@ -56,6 +56,21 @@ are "attached" to a given AST node, and TypeScript doesn't see those comments as attached to anything. In the future we could explore implementing our own comment attachment, but it is a difficult problem. +## Const-array-backed `@gqlEnum` aliases are intentionally narrow + +Grats supports enum aliases written as `type MyEnum = typeof VALUES[number]`, +but this support is intentionally syntax-based and local. + +Today, Grats expects `VALUES` to be a local top-level `const` initialized as a +direct `as const` array literal. Forms like imports, alias chains, and +spread/computed arrays are currently not supported. + +This is a deliberate tradeoff to keep extraction predictable and avoid depending +on broader semantic evaluation during extraction. + +When using this form, Grats also validates that the array is non-empty, values +are unique, and each value is a valid GraphQL enum name. + ## Alternate comment types It would be nice if Grats supported other comment types, such as regular block