Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions llm-docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

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.

- **Features**
- Added support for deriving `@gqlEnum` from const arrays (`(typeof X)[number]`) and const objects (`(typeof X)[keyof typeof X]`). This allows defining enums with runtime-accessible values without using TypeScript's `enum` syntax. The const declaration must immediately precede the type alias. See [enum docs](./docblock-tags/enums.md#runtime-accessible-enums) for details.

## 0.0.36

- **Breaking Changes**
Expand Down
59 changes: 59 additions & 0 deletions llm-docs/docblock-tags/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 deriving from a const array (`(typeof X)[number]`) or const object (`(typeof X)[keyof typeof X]`)

```ts
/**
Expand Down Expand Up @@ -43,3 +44,61 @@ This is due to the fact that TypeScript does not see JSDoc comments as "attachin
/** @gqlEnum */
type MyEnum = "OK" | "ERROR";
```

## Runtime-accessible enums

If you need runtime access to enum values without using TypeScript's `enum` syntax, Grats supports deriving enums from const arrays and const objects.

> **INFO:**
> The const declaration must be the immediately preceding statement before the `@gqlEnum` type alias. This ensures the actual list of enum values are colocated with the `@gqlEnum` annotation.

### Const array

```tsx
const ALL_STATUSES = ["DRAFT", "PUBLISHED", "ARCHIVED"] as const;

/** @gqlEnum */
type Status = (typeof ALL_STATUSES)[number];
```

_Generated GraphQL schema:_

```graphql
enum Status {
ARCHIVED
DRAFT
PUBLISHED
}
```

Like union-of-literal enums, const arrays do not support descriptions or `@deprecated` on individual values. Use a const object or TypeScript `enum` if you need those.

### Const object

Const objects allow you to define human-readable keys that map to GraphQL enum values, similar to TypeScript `enum` declarations. Unlike arrays, object properties support descriptions and `@deprecated` tags:

```tsx
const Status = {
/** Currently being edited */
Draft: "DRAFT",
/** Available to readers */
Published: "PUBLISHED",
/** @deprecated Use DRAFT instead */
Hidden: "HIDDEN",
} as const;

/** @gqlEnum */
type Status = (typeof Status)[keyof typeof Status];
```

_Generated GraphQL schema:_

```graphql
enum Status {
"""Currently being edited"""
DRAFT
HIDDEN @deprecated(reason: "Use DRAFT instead")
"""Available to readers"""
PUBLISHED
}
```
18 changes: 17 additions & 1 deletion src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/object type query (e.g. \`(typeof VALUES)[number]\` or \`(typeof OBJ)[keyof typeof OBJ]\`). For example: \`type MyEnum = "foo" | "bar"\` or \`type MyEnum = "foo"\`.`;
}

export function enumVariantNotStringLiteral() {
Expand All @@ -247,6 +247,22 @@ 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 enumConstMustPrecedeTypeAlias() {
return `When deriving a \`@${ENUM_TAG}\` from a const value using \`typeof\`, the const declaration must be the immediately preceding statement. Grats requires this co-location to ensure it's clear which declarations contribute to the GraphQL schema. For example:\n\nconst VALUES = ["FOO", "BAR"] as const;\n\n/** @${ENUM_TAG} */\ntype MyEnum = (typeof VALUES)[number];`;
}

export function enumConstMissingAsConst() {
return `Expected the const declaration preceding this \`@${ENUM_TAG}\` to use \`as const\`. Grats needs the literal types to determine the enum values. For example: \`const VALUES = ["FOO", "BAR"] as const;\``;
}

export function enumConstInvalidExpression() {
return `Expected the const declaration preceding this \`@${ENUM_TAG}\` to be an array literal or object literal with \`as const\`. For example: \`const VALUES = ["FOO", "BAR"] as const;\` or \`const OBJ = { Foo: "FOO" } as const;\``;
}

export function enumConstNameMismatch(expected: string, found: string) {
return `Expected the \`const\` declaration immediately before this \`@${ENUM_TAG}\` to be named \`${expected}\` (to match \`typeof ${expected}\`), but found \`${found}\`. The \`const\` referenced in the type must be the immediately preceding statement. Grats requires this co-location to ensure it's clear which declarations contribute to the GraphQL schema.`;
}

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.";
}
Expand Down
237 changes: 237 additions & 0 deletions src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2170,6 +2170,10 @@ class Extractor {
];
}

if (ts.isIndexedAccessTypeNode(node.type)) {
return this.enumTypeAliasFromPrecedingConst(node, node.type);
}

if (!ts.isUnionTypeNode(node.type)) {
this.reportUnhandled(node.type, "union", E.enumTagOnInvalidNode());
return null;
Expand Down Expand Up @@ -2207,6 +2211,239 @@ class Extractor {
return values;
}

/**
* Handle `@gqlEnum` type aliases that derive their values from a preceding
* const declaration. Supports two patterns:
*
* - Const array: `(typeof VALUES)[number]`
* - Const object: `(typeof OBJ)[keyof typeof OBJ]`
*
* The const declaration must be the immediately preceding statement to ensure
* it's clear which declarations contribute to the GraphQL schema.
*/
enumTypeAliasFromPrecedingConst(
node: ts.TypeAliasDeclaration,
indexedAccess: ts.IndexedAccessTypeNode,
): EnumValueDefinitionNode[] | null {
// Unwrap parenthesized types: `(typeof X)[number]` vs `typeof X[number]`
let objectType: ts.TypeNode = indexedAccess.objectType;
while (ts.isParenthesizedTypeNode(objectType)) {
objectType = objectType.type;
}

// The object type must be `typeof X` where X is an identifier
if (
!ts.isTypeQueryNode(objectType) ||
!ts.isIdentifier(objectType.exprName)
) {
this.reportUnhandled(indexedAccess, "union", E.enumTagOnInvalidNode());
return null;
}

const referencedName = objectType.exprName.text;

// Determine the pattern from the index type
let isArrayPattern: boolean;
if (indexedAccess.indexType.kind === ts.SyntaxKind.NumberKeyword) {
// (typeof X)[number] — const array
isArrayPattern = true;
} else if (
ts.isTypeOperatorNode(indexedAccess.indexType) &&
indexedAccess.indexType.operator === ts.SyntaxKind.KeyOfKeyword &&
ts.isTypeQueryNode(indexedAccess.indexType.type) &&
ts.isIdentifier(indexedAccess.indexType.type.exprName) &&
indexedAccess.indexType.type.exprName.text === referencedName
) {
// (typeof X)[keyof typeof X] — const object
isArrayPattern = false;
} else {
this.reportUnhandled(indexedAccess, "union", E.enumTagOnInvalidNode());
return null;
}

// Find the preceding statement in the containing block (source file,
// namespace body, etc.)
const container = node.parent;
if (!("statements" in container)) {
this.report(indexedAccess, E.enumConstMustPrecedeTypeAlias());
return null;
}
const statements = container.statements as ts.NodeArray<ts.Statement>;
const nodeIndex = statements.indexOf(node);
if (nodeIndex <= 0) {
this.report(indexedAccess, E.enumConstMustPrecedeTypeAlias());
return null;
}

const precedingStatement = statements[nodeIndex - 1];

// Preceding statement must be a const variable statement
if (
!ts.isVariableStatement(precedingStatement) ||
(precedingStatement.declarationList.flags & ts.NodeFlags.Const) === 0
) {
this.report(indexedAccess, E.enumConstMustPrecedeTypeAlias());
return null;
}

const declarations = precedingStatement.declarationList.declarations;
if (declarations.length !== 1) {
this.report(indexedAccess, E.enumConstMustPrecedeTypeAlias());
return null;
}

const declaration = declarations[0];
if (!ts.isIdentifier(declaration.name)) {
this.report(indexedAccess, E.enumConstMustPrecedeTypeAlias());
return null;
}

// Validate the name matches
if (declaration.name.text !== referencedName) {
this.report(
indexedAccess,
E.enumConstNameMismatch(referencedName, declaration.name.text),
);
return null;
}

// Extract the `as const` expression, handling both `X as const` and `X as const satisfies T`
const constExpr = this.extractAsConstExpression(declaration);
if (constExpr == null) {
this.report(indexedAccess, E.enumConstMissingAsConst());
return null;
}

if (isArrayPattern) {
return this.enumValuesFromArrayLiteral(node, constExpr);
} else {
return this.enumValuesFromObjectLiteral(node, constExpr);
}
}

/**
* Given a variable declaration, extract the inner expression from an
* `as const` or `as const satisfies T` assertion. Returns null if the
* declaration doesn't use `as const`.
*/
extractAsConstExpression(
declaration: ts.VariableDeclaration,
): ts.Expression | null {
if (declaration.initializer == null) {
return null;
}

let expr = declaration.initializer;

// Handle `X as const satisfies T` — the satisfies wraps the as-expression
if (ts.isSatisfiesExpression(expr)) {
expr = expr.expression;
}

// Must be `X as const`
if (!ts.isAsExpression(expr)) {
return null;
}

if (
!ts.isTypeReferenceNode(expr.type) ||
!ts.isIdentifier(expr.type.typeName) ||
expr.type.typeName.text !== "const"
) {
return null;
}

return expr.expression;
}

enumValuesFromArrayLiteral(
node: ts.TypeAliasDeclaration,
expr: ts.Expression,
): EnumValueDefinitionNode[] | null {
if (!ts.isArrayLiteralExpression(expr)) {
this.report(expr, E.enumConstInvalidExpression());
return null;
}

const values: EnumValueDefinitionNode[] = [];

for (const element of expr.elements) {
if (!ts.isStringLiteral(element)) {
this.reportUnhandled(
element,
"union member",
E.enumVariantNotStringLiteral(),
);
continue;
}

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;
}

enumValuesFromObjectLiteral(
node: ts.TypeAliasDeclaration,
expr: ts.Expression,
): EnumValueDefinitionNode[] | null {
if (!ts.isObjectLiteralExpression(expr)) {
this.report(expr, E.enumConstInvalidExpression());
return null;
}

const values: EnumValueDefinitionNode[] = [];

for (const prop of expr.properties) {
if (
!ts.isPropertyAssignment(prop) ||
!ts.isStringLiteral(prop.initializer)
) {
this.reportUnhandled(
prop,
"enum value",
E.enumVariantNotStringLiteral(),
);
continue;
}

const value = prop.initializer;

const errorMessage = graphQLNameValidationMessage(value.text);
if (errorMessage != null) {
this.report(value, errorMessage);
}

const description = this.collectDescription(prop);
const directives = this.collectDirectives(prop);

values.push(
this.gql.enumValueDefinition(
prop,
this.gql.name(value, value.text),
directives,
description,
prop.name.getText(),
),
);
}

return values;
}

collectEnumValues(
node: ts.EnumDeclaration,
): ReadonlyArray<EnumValueDefinitionNode> {
Expand Down
16 changes: 14 additions & 2 deletions src/TypeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,20 @@ export class TypeContext implements ITypeContext, ITypeContextForResolveTypes {

private findSymbolDeclaration(startSymbol: ts.Symbol): ts.Declaration | null {
const symbol = this.resolveSymbol(startSymbol);
const declaration = symbol.declarations?.[0];
return declaration ?? null;
const declarations = symbol.declarations;
if (declarations == null || declarations.length === 0) {
return null;
}
// When a symbol has multiple declarations (e.g., `const X` and `type X`
// sharing a name), prefer the one registered in the GraphQL schema.
if (declarations.length > 1) {
for (const decl of declarations) {
if (this._declarationToDefinition.has(decl)) {
return decl;
}
}
}
return declarations[0];
}

// Follow symbol aliases until we find the original symbol. Accounts for
Expand Down
10 changes: 10 additions & 0 deletions src/tests/fixtures/enums/EnumFromConstArray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const ALL_STATUSES = ["DRAFT", "PUBLISHED", "ARCHIVED"] as const;

/** @gqlEnum */
type ShowStatus = (typeof ALL_STATUSES)[number];

/** @gqlType */
class Show {
/** @gqlField */
status: ShowStatus;
}
Loading
Loading