Skip to content

Commit 954e220

Browse files
committed
Add GraphQL spec validations to $onValidate
Add early validations per GraphQL spec: - empty-enum: Enums must define at least one value - empty-union: Unions must have at least one non-null member - reserved-name: Names must not begin with "__" (reserved for introspection) The reserved-name check validates: - Type names (models, enums, unions) - Field/property names - Operation parameter names - Enum member names The empty-union check catches unions that will be empty after null-stripping (e.g., `union Foo { none: null }`) before the mutation engine runs. Spec references: - https://spec.graphql.org/September2025/#sec-Enums - https://spec.graphql.org/September2025/#sec-Unions - https://spec.graphql.org/September2025/#sec-Names.Reserved-Names
1 parent 72a4d71 commit 954e220

3 files changed

Lines changed: 362 additions & 1 deletion

File tree

packages/graphql/src/lib.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,18 @@ export const libDef = {
163163
"GraphQL schema has no operations. At minimum a Query root type is required.",
164164
},
165165
},
166+
"empty-enum": {
167+
severity: "error",
168+
messages: {
169+
default: paramMessage`Enum "${"name"}" must define at least one value. GraphQL enums cannot be empty.`,
170+
},
171+
},
172+
"reserved-name": {
173+
severity: "error",
174+
messages: {
175+
default: paramMessage`Name "${"name"}" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.`,
176+
},
177+
},
166178
},
167179
emitter: {
168180
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,

packages/graphql/src/validate.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { type Namespace, navigateTypesInNamespace, type Program } from "@typespec/compiler";
1+
import {
2+
type DiagnosticTarget,
3+
type Enum,
4+
isNullType,
5+
type Model,
6+
type Namespace,
7+
type Operation,
8+
navigateTypesInNamespace,
9+
type Program,
10+
type Union,
11+
} from "@typespec/compiler";
212
import { reportDiagnostic } from "./lib.js";
313
import { getOperationKind } from "./lib/operation-kind.js";
414
import { listSchemas } from "./lib/schema.js";
@@ -25,6 +35,16 @@ function validateSchema(program: Program, ns: Namespace) {
2535
if (getOperationKind(program, op) !== undefined) {
2636
hasGraphQLOps = true;
2737
}
38+
validateOperation(program, op);
39+
},
40+
model(model) {
41+
validateModel(program, model);
42+
},
43+
enum(enumType) {
44+
validateEnum(program, enumType);
45+
},
46+
union(unionType) {
47+
validateUnion(program, unionType);
2848
},
2949
});
3050

@@ -35,3 +55,92 @@ function validateSchema(program: Program, ns: Namespace) {
3555
});
3656
}
3757
}
58+
59+
/**
60+
* GraphQL spec: Names must not begin with "__" (two underscores).
61+
* https://spec.graphql.org/September2025/#sec-Names.Reserved-Names
62+
*/
63+
function validateReservedName(program: Program, name: string, target: DiagnosticTarget) {
64+
if (name.startsWith("__")) {
65+
reportDiagnostic(program, {
66+
code: "reserved-name",
67+
format: { name },
68+
target,
69+
});
70+
}
71+
}
72+
73+
/**
74+
* Validate model: check type name and property names for reserved prefix.
75+
*/
76+
function validateModel(program: Program, model: Model) {
77+
// Check model name
78+
if (model.name) {
79+
validateReservedName(program, model.name, model);
80+
}
81+
82+
// Check property names
83+
for (const prop of model.properties.values()) {
84+
validateReservedName(program, prop.name, prop);
85+
}
86+
}
87+
88+
/**
89+
* Validate operation: check parameter names for reserved prefix.
90+
*/
91+
function validateOperation(program: Program, op: Operation) {
92+
for (const param of op.parameters.properties.values()) {
93+
validateReservedName(program, param.name, param);
94+
}
95+
}
96+
97+
/**
98+
* Validate union: check type name for reserved prefix and empty unions.
99+
* https://spec.graphql.org/September2025/#sec-Unions
100+
*/
101+
function validateUnion(program: Program, unionType: Union) {
102+
// Only validate named unions (not anonymous unions like `string | null`)
103+
if (!unionType.name) {
104+
return;
105+
}
106+
107+
validateReservedName(program, unionType.name, unionType);
108+
109+
// Check for empty union: no variants, or all variants are null.
110+
// GraphQL unions must have at least one member type.
111+
const nonNullVariants = [...unionType.variants.values()].filter(
112+
(v) => !isNullType(v.type),
113+
);
114+
115+
if (nonNullVariants.length === 0) {
116+
reportDiagnostic(program, {
117+
code: "empty-union",
118+
target: unionType,
119+
});
120+
}
121+
}
122+
123+
/**
124+
* GraphQL spec: Enums must define at least one value.
125+
* https://spec.graphql.org/September2025/#sec-Enums
126+
*/
127+
function validateEnum(program: Program, enumType: Enum) {
128+
// Check enum name
129+
if (enumType.name) {
130+
validateReservedName(program, enumType.name, enumType);
131+
}
132+
133+
// Check enum member names
134+
for (const member of enumType.members.values()) {
135+
validateReservedName(program, member.name, member);
136+
}
137+
138+
// Check for empty enum
139+
if (enumType.members.size === 0) {
140+
reportDiagnostic(program, {
141+
code: "empty-enum",
142+
format: { name: enumType.name },
143+
target: enumType,
144+
});
145+
}
146+
}

packages/graphql/test/validate.test.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,244 @@ describe("$onValidate", () => {
6363
expectDiagnosticEmpty(diagnostics);
6464
});
6565
});
66+
67+
describe("empty-enum", () => {
68+
it("reports error for enum with no values", async () => {
69+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
70+
@schema
71+
namespace TestNamespace {
72+
enum Status {}
73+
model Book { status: Status; }
74+
@query op getBooks(): Book[];
75+
}
76+
`);
77+
78+
expectDiagnostics(diagnostics, {
79+
code: "@typespec/graphql/empty-enum",
80+
severity: "error",
81+
message: 'Enum "Status" must define at least one value. GraphQL enums cannot be empty.',
82+
});
83+
});
84+
85+
it("does not report error for enum with values", async () => {
86+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
87+
@schema
88+
namespace TestNamespace {
89+
enum Status { Active, Inactive }
90+
model Book { status: Status; }
91+
@query op getBooks(): Book[];
92+
}
93+
`);
94+
95+
expectDiagnosticEmpty(diagnostics);
96+
});
97+
});
98+
99+
describe("reserved-name", () => {
100+
it("reports error for model name starting with __", async () => {
101+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
102+
@schema
103+
namespace TestNamespace {
104+
model __Reserved { title: string; }
105+
@query op get(): __Reserved;
106+
}
107+
`);
108+
109+
expectDiagnostics(diagnostics, {
110+
code: "@typespec/graphql/reserved-name",
111+
severity: "error",
112+
message:
113+
'Name "__Reserved" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
114+
});
115+
});
116+
117+
it("reports error for property name starting with __", async () => {
118+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
119+
@schema
120+
namespace TestNamespace {
121+
model Book { __internal: string; }
122+
@query op getBooks(): Book[];
123+
}
124+
`);
125+
126+
expectDiagnostics(diagnostics, {
127+
code: "@typespec/graphql/reserved-name",
128+
severity: "error",
129+
message:
130+
'Name "__internal" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
131+
});
132+
});
133+
134+
it("reports error for operation parameter starting with __", async () => {
135+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
136+
@schema
137+
namespace TestNamespace {
138+
model Book { title: string; }
139+
@query op getBook(__id: string): Book;
140+
}
141+
`);
142+
143+
expectDiagnostics(diagnostics, {
144+
code: "@typespec/graphql/reserved-name",
145+
severity: "error",
146+
message:
147+
'Name "__id" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
148+
});
149+
});
150+
151+
it("reports error for enum name starting with __", async () => {
152+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
153+
@schema
154+
namespace TestNamespace {
155+
enum __Status { Active }
156+
model Book { status: __Status; }
157+
@query op getBooks(): Book[];
158+
}
159+
`);
160+
161+
expectDiagnostics(diagnostics, {
162+
code: "@typespec/graphql/reserved-name",
163+
severity: "error",
164+
message:
165+
'Name "__Status" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
166+
});
167+
});
168+
169+
it("reports error for enum member starting with __", async () => {
170+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
171+
@schema
172+
namespace TestNamespace {
173+
enum Status { __Internal, Active }
174+
model Book { status: Status; }
175+
@query op getBooks(): Book[];
176+
}
177+
`);
178+
179+
expectDiagnostics(diagnostics, {
180+
code: "@typespec/graphql/reserved-name",
181+
severity: "error",
182+
message:
183+
'Name "__Internal" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
184+
});
185+
});
186+
187+
it("reports error for union name starting with __", async () => {
188+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
189+
@schema
190+
namespace TestNamespace {
191+
model Cat { meow: string; }
192+
model Dog { bark: string; }
193+
union __Pet { cat: Cat, dog: Dog }
194+
@query op getPet(): __Pet;
195+
}
196+
`);
197+
198+
expectDiagnostics(diagnostics, {
199+
code: "@typespec/graphql/reserved-name",
200+
severity: "error",
201+
message:
202+
'Name "__Pet" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
203+
});
204+
});
205+
206+
it("does not report error for names with single underscore prefix", async () => {
207+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
208+
@schema
209+
namespace TestNamespace {
210+
model _Book { _title: string; }
211+
@query op _getBooks(_filter: string): _Book[];
212+
}
213+
`);
214+
215+
expectDiagnosticEmpty(diagnostics);
216+
});
217+
218+
it("does not report error for names with underscore in middle", async () => {
219+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
220+
@schema
221+
namespace TestNamespace {
222+
model My__Book { my__title: string; }
223+
@query op get__Books(): My__Book[];
224+
}
225+
`);
226+
227+
expectDiagnosticEmpty(diagnostics);
228+
});
229+
});
230+
231+
describe("empty-union", () => {
232+
it("reports error for union with no variants", async () => {
233+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
234+
@schema
235+
namespace TestNamespace {
236+
union Empty {}
237+
@query op get(): Empty;
238+
}
239+
`);
240+
241+
expectDiagnostics(diagnostics, {
242+
code: "@typespec/graphql/empty-union",
243+
severity: "error",
244+
message:
245+
"Union has no non-null variants. A GraphQL union must contain at least one member type.",
246+
});
247+
});
248+
249+
it("reports error for union with only null variant", async () => {
250+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
251+
@schema
252+
namespace TestNamespace {
253+
union MaybeNothing { nothing: null }
254+
@query op get(): MaybeNothing;
255+
}
256+
`);
257+
258+
expectDiagnostics(diagnostics, {
259+
code: "@typespec/graphql/empty-union",
260+
severity: "error",
261+
message:
262+
"Union has no non-null variants. A GraphQL union must contain at least one member type.",
263+
});
264+
});
265+
266+
it("does not report error for union with non-null variants", async () => {
267+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
268+
@schema
269+
namespace TestNamespace {
270+
model Cat { meow: string; }
271+
model Dog { bark: string; }
272+
union Pet { cat: Cat, dog: Dog }
273+
@query op getPet(): Pet;
274+
}
275+
`);
276+
277+
expectDiagnosticEmpty(diagnostics);
278+
});
279+
280+
it("does not report error for union with null and non-null variants", async () => {
281+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
282+
@schema
283+
namespace TestNamespace {
284+
model Cat { meow: string; }
285+
union MaybeCat { cat: Cat, none: null }
286+
@query op getCat(): MaybeCat;
287+
}
288+
`);
289+
290+
expectDiagnosticEmpty(diagnostics);
291+
});
292+
293+
it("does not validate anonymous unions", async () => {
294+
// Anonymous unions like `string | null` are handled differently
295+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
296+
@schema
297+
namespace TestNamespace {
298+
model Book { title: string | null; }
299+
@query op getBooks(): Book[];
300+
}
301+
`);
302+
303+
expectDiagnosticEmpty(diagnostics);
304+
});
305+
});
66306
});

0 commit comments

Comments
 (0)