typespec-http-zod 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1015 @@
1
+ import { getDiscriminatedUnion, getDiscriminator, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
2
+ import { createMetadataInfo, isQueryParam, Visibility } from "@typespec/http";
3
+ import { applyConstraints } from "./constraints.js";
4
+ import { reportDiagnostic } from "./lib.js";
5
+ /**
6
+ * What a walk returns after reporting a refusal.
7
+ *
8
+ * ⚠️ **Reporting does not unwind, which is the point.** A throw abandoned the compile at the first
9
+ * unrepresentable construct; reporting lets the walk carry on and name every one of them in a single
10
+ * run. The walk still has to return a `string`, and `z.never()` is the honest placeholder: valid
11
+ * Zod, rejects everything, and unreachable in practice because the error diagnostic has already
12
+ * failed the compile that would have written it.
13
+ */
14
+ const UNREPRESENTABLE = "z.never()";
15
+ /**
16
+ * TypeSpec type → Zod source text.
17
+ *
18
+ * **Deliberately partial, and it fails loudly rather than guessing.** A general TypeSpec→Zod
19
+ * converter has to answer for every construct the language can express; this one only has to answer
20
+ * for the constructs our own specs are allowed to use, which is what makes it a bounded problem
21
+ * rather than an open-ended dependency (see the ADR on why `typespec-zod@0.0.0-68` is not in the
22
+ * runtime path). Anything outside that set throws with the offending type named — an emitter that
23
+ * silently produced `z.any()` for a construct it did not understand would push a hole all the way
24
+ * into `respond()`, where the whole point is that drift is caught.
25
+ */
26
+ /**
27
+ * External module → the constants the emitted schemas reference from it.
28
+ *
29
+ * Collected during emission rather than pre-scanned: only the enums actually reachable from an
30
+ * operation should produce an import, and reachability is exactly what the walk already computes.
31
+ *
32
+ * ⚠️ **Scoped to one emission, not module-global-and-cleared.** The previous shape was a module
33
+ * `Map` emptied by whoever read it first, which is correct only while there is exactly one service
34
+ * and one read. A second `@service` namespace — a public surface beside an internal one — would
35
+ * have merged one surface's imports into the other's file: an unused import if lucky, a missing one
36
+ * if not. Neither is discoverable while there is only one service, so it is fixed before there are
37
+ * two rather than found by them. Same save/restore idiom as {@link withRefResolver}.
38
+ */
39
+ let externalImports;
40
+ /**
41
+ * Enum name → its declared members, for every vocabulary the surface actually reaches.
42
+ *
43
+ * The spec is the single source for these. A consumer's shared package used to hold them as tuples
44
+ * and the spec merely *described* them for the document's benefit — two lists that agreed only by
45
+ * inspection, and by the time this flipped, two of them did not: `PersonalAttribute` published four
46
+ * of six values wrong (the charge-authentication answers an MR01 cannot be filed without) and
47
+ * `SubmissionStatusCode` published all five wrong. Generating them removes the second list.
48
+ */
49
+ let vocabularies;
50
+ /**
51
+ * Whether a model is SEALED — closed to properties nobody declared.
52
+ *
53
+ * ⚠️ **A property of the model's own shape, not of where it is used.** This was previously "the
54
+ * model is reachable from a request body", computed by a whole-graph pre-pass. That rule is wrong in
55
+ * a way that is invisible while it happens to agree: there is one declaration per model, so adding a
56
+ * single operation that accepts `ProblemItem` in a body would flip `problemItemSchema` from
57
+ * stripping to rejecting **in every response that uses it too**. Strictness would be decided by the
58
+ * rest of the spec rather than by the model.
59
+ *
60
+ * ⚠️ **This mirrors `@typespec/openapi3`'s `shouldSealSchema` deliberately, line for line.** The
61
+ * emitted validator and the published document have to answer the same question the same way, and
62
+ * the only way to guarantee that is to ask it the same way — not to arrive at the same answer by a
63
+ * different route and measure that they still agree. The exception for models with derived models is
64
+ * theirs: sealing a base would make a subtype's own properties "unevaluated" against it.
65
+ *
66
+ * The one thing not mirrored is the `seal-object-schemas` option itself. openapi3 seals only when it
67
+ * is on; a validator that refuses a property the document permits is wrong in the other direction,
68
+ * so a consumer running with sealing off wants schemas that strip rather than reject. That is
69
+ * `seal-object-schemas` in `EmitterOptions`, defaulting to openapi3's own default.
70
+ */
71
+ function isSealed(model) {
72
+ // `Record<never>` is closed whatever the option says — the spec has stated it outright, and
73
+ // openapi3 seals it for the same reason.
74
+ if (model.indexer !== undefined)
75
+ return isNeverType(model.indexer.value);
76
+ if (!sealObjectSchemas)
77
+ return false;
78
+ return !model.derivedModels.some(includeDerivedModel);
79
+ }
80
+ /**
81
+ * Whether `@typespec/openapi3` is sealing object schemas for this compile.
82
+ *
83
+ * ⚠️ **This emitter cannot see the other emitter's options, so the consumer states it twice.**
84
+ * Awkward, and the alternative is worse: assume sealing is on and a project running openapi3 with
85
+ * its default gets validators that reject properties its published document permits. Whichever way
86
+ * the two are configured, the conformance differential compares openness in both directions and
87
+ * fails on a mismatch — so a project that sets one and forgets the other is told immediately rather
88
+ * than shipping a contradiction.
89
+ */
90
+ let sealObjectSchemas = false;
91
+ export function withSealedObjects(sealed, run) {
92
+ const previous = sealObjectSchemas;
93
+ sealObjectSchemas = sealed;
94
+ try {
95
+ return run();
96
+ }
97
+ finally {
98
+ sealObjectSchemas = previous;
99
+ }
100
+ }
101
+ /**
102
+ * The visibility the current walk is emitting for — which decides what counts as HTTP metadata.
103
+ *
104
+ * Visibility decides **which** metadata applies: `@statusCode` is applicable to a response and not
105
+ * to a request, so the same model projects differently in the two directions. `createMetadataInfo`
106
+ * is the predicate `@typespec/openapi3` itself uses, so asking it is what keeps the two artefacts
107
+ * from answering differently. Measured on `type/model/visibility`: openapi3 emits five components
108
+ * for one model, and before this we emitted one carrying every lifecycle property at once.
109
+ *
110
+ * ⚠️ **Position is a SECOND axis, and this only handles the first.** `@header` is metadata at a
111
+ * payload root and ordinary data nested inside a `@body`; `isPayloadProperty` takes a third
112
+ * argument, `ignoreMetadataAnnotations`, which openapi3 threads as emitter context
113
+ * (`schema-emitter.js:210`) and this does not pass. A metadata-carrying model used in **both**
114
+ * positions therefore still loses the header property where the document keeps it.
115
+ *
116
+ * That shape is rare rather than theoretical: it raises `metadata-ignored` from `@typespec/http`,
117
+ * which fires **zero** times across all 65 `@typespec/http-specs` scenarios,
118
+ * and one variant of it makes openapi3 itself fail with `duplicate-type-name`. It is a real gap with
119
+ * no known occurrence — recorded here rather than described as an incident, because it has not been
120
+ * one.
121
+ *
122
+ * Scoped rather than threaded through six signatures, matching the idiom the rest of this module
123
+ * already uses — the walk is deeply recursive and a parameter would have to be carried by every
124
+ * function whether it cared or not.
125
+ */
126
+ let visibility = Visibility.Read;
127
+ let metadataInfo;
128
+ export function withVisibility(program, next, run) {
129
+ const previousVisibility = visibility;
130
+ const previousInfo = metadataInfo;
131
+ visibility = next;
132
+ metadataInfo ??= createMetadataInfo(program);
133
+ try {
134
+ return run();
135
+ }
136
+ finally {
137
+ visibility = previousVisibility;
138
+ metadataInfo = previousInfo;
139
+ }
140
+ }
141
+ /** Whether a property is optional AT THE CURRENT POSITION — PATCH makes update fields optional. */
142
+ function isOptionalAt(program, property) {
143
+ metadataInfo ??= createMetadataInfo(program);
144
+ return metadataInfo.isOptional(property, visibility);
145
+ }
146
+ /** Whether a property belongs in the BODY at the current position, rather than being metadata. */
147
+ function isPayloadProperty(program, property) {
148
+ metadataInfo ??= createMetadataInfo(program);
149
+ return metadataInfo.isPayloadProperty(property, visibility);
150
+ }
151
+ /** Whether this model is shaped differently at `visibility` than at the canonical `Read`. */
152
+ export function isTransformedBy(program, model, at) {
153
+ metadataInfo ??= createMetadataInfo(program);
154
+ return metadataInfo.isTransformed(model, at);
155
+ }
156
+ /** The visibility the current walk is emitting for — the registry keys declarations on it. */
157
+ export function currentVisibility() {
158
+ return visibility;
159
+ }
160
+ /**
161
+ * openapi3's own rule for which derived models count — `@typespec/openapi3`'s `util.ts`.
162
+ *
163
+ * A template declaration is not a real subtype, and neither is an uninstantiated template; both
164
+ * would otherwise stop a base from being sealed for no reason a reader could see.
165
+ */
166
+ function includeDerivedModel(model) {
167
+ return (!isTemplateDeclaration(model) &&
168
+ (model.templateMapper?.args === undefined ||
169
+ model.templateMapper.args.length === 0 ||
170
+ model.derivedModels.length > 0));
171
+ }
172
+ /** Record an enum's members so the vocabularies artefact can be generated from them. */
173
+ function noteVocabulary(name, values) {
174
+ vocabularies?.set(name, values);
175
+ }
176
+ /**
177
+ * Record every enum a service DECLARES, not merely every one its success bodies happen to reach.
178
+ *
179
+ * ⚠️ **A vocabulary is a fact about the contract, not about a code path.** Collecting them during the
180
+ * Zod walk meant an enum reachable only through an ERROR body was never emitted — which is exactly
181
+ * what happened to one surface's error-code enum: declared in the spec, published on every
182
+ * operation's failure responses, and absent from the generated vocabularies, so the one list a
183
+ * consumer branches on had to be hand-written beside the generated ones. Walking declarations removes
184
+ * the whole class rather than the instance.
185
+ */
186
+ export function noteDeclaredVocabularies(enums) {
187
+ for (const target of enums) {
188
+ noteVocabulary(target.name, [...target.members.values()].map((member) => String(member.value ?? member.name)));
189
+ }
190
+ }
191
+ /**
192
+ * Record a named model's PROPERTY NAMES as a runtime vocabulary.
193
+ *
194
+ * ⚠️ **This exists because "a `Record` keyed by an enum" is inexpressible, and the workaround was
195
+ * about to cost a duplicated list.** `ProfileAttributes` is 150-odd known keys; naming them in the
196
+ * spec is what makes the published document state the constraint instead of hiding it behind a
197
+ * hand-written predicate. But a *type* cannot be iterated, and more than one layer usually needs
198
+ * the set at RUN time — so without this the keys would live in the spec AND as a hand-maintained
199
+ * tuple in a shared package — one vocabulary stated twice, which is precisely the
200
+ * drift the deleted predicate existed to prevent.
201
+ *
202
+ * ⚠️ **Selected by an emitter OPTION, deliberately not by a decorator.** A decorator would put a
203
+ * mark in the spec that no document carries — the exact objection that deleted the last four. This
204
+ * changes no schema, states nothing in the contract and enforces nothing; it decides which
205
+ * convenience artefact gets written beside the types, which is what an emitter option is for.
206
+ *
207
+ * Wire names, like everything else here: the document publishes one name per property, and a
208
+ * consumer checking a key against this tuple is checking what arrived.
209
+ */
210
+ export function noteKeyVocabularies(program, models, wanted) {
211
+ const found = [];
212
+ for (const model of models) {
213
+ if (!wanted.includes(model.name))
214
+ continue;
215
+ found.push(model.name);
216
+ noteVocabulary(model.name, inheritedAndOwnProperties(model).map((property) => rawPropertyKey(program, property)));
217
+ }
218
+ return found;
219
+ }
220
+ /** Record that the emitted schemas reference `name` from `module`. */
221
+ export function noteExternalImport(module, name) {
222
+ if (externalImports === undefined) {
223
+ throw new Error("typespec-http-zod: an external import was noted outside `collectExternalImports`. " +
224
+ "Every walk must run inside one, or its imports land in another surface's file.");
225
+ }
226
+ const names = externalImports.get(module) ?? new Set();
227
+ names.add(name);
228
+ externalImports.set(module, names);
229
+ }
230
+ /**
231
+ * Which package the generated Zod imports its vocabularies from — `undefined` when there is none.
232
+ *
233
+ * ⚠️ **Not a constant, and no longer defaulted.** Each `@service` publishes its own contracts
234
+ * package, so a hardcoded specifier makes the second service's schemas import names that package
235
+ * does not export. Scoped the same way the strict-model set is, so a nested emission cannot leak
236
+ * into its parent.
237
+ *
238
+ * ⚠️ **It used to default to one repository's own package name.** A
239
+ * consumer who configured nothing got validators importing `SPEC_VOCABULARIES` from a package they
240
+ * had never heard of — output that looks right and does not resolve. There is no honest default for
241
+ * "the caller's own package", so the absence is the answer: with no package named, an enum is
242
+ * emitted inline and the output depends on nothing.
243
+ */
244
+ let contractsPackageName;
245
+ const contractsPackage = () => contractsPackageName;
246
+ export function withContractsPackage(name, run) {
247
+ const previous = contractsPackageName;
248
+ contractsPackageName = name;
249
+ try {
250
+ return run();
251
+ }
252
+ finally {
253
+ contractsPackageName = previous;
254
+ }
255
+ }
256
+ /**
257
+ * Run `walk` with a fresh import collector, and hand back what it referenced.
258
+ *
259
+ * Throwing rather than lazily creating the map is deliberate: a walk that runs outside a collector
260
+ * is a wiring mistake whose only symptom would be a generated file missing an import, which the
261
+ * emitted TypeScript reports as an undefined name far from the cause.
262
+ */
263
+ export function collectExternalImports(walk) {
264
+ const previousImports = externalImports;
265
+ const previousVocabularies = vocabularies;
266
+ const imports = new Map();
267
+ const vocabulary = new Map();
268
+ externalImports = imports;
269
+ vocabularies = vocabulary;
270
+ try {
271
+ return { result: walk(), imports, vocabularies: vocabulary };
272
+ }
273
+ finally {
274
+ externalImports = previousImports;
275
+ vocabularies = previousVocabularies;
276
+ }
277
+ }
278
+ /** Refuse `type`, pointing at its declaration, and carry on so the rest is reported too. */
279
+ function refuse(program, type, why) {
280
+ reportDiagnostic(program, {
281
+ code: "unsupported-type",
282
+ target: type,
283
+ format: { artefact: "Zod", kind: type.kind, why },
284
+ });
285
+ return UNREPRESENTABLE;
286
+ }
287
+ /** Scalars we accept, and the Zod they become. Keyed by the TypeSpec scalar's declared name. */
288
+ const SCALARS = {
289
+ string: "z.string()",
290
+ boolean: "z.boolean()",
291
+ bytes: "z.string()",
292
+ int8: "z.number().int()",
293
+ int16: "z.number().int()",
294
+ int32: "z.number().int()",
295
+ int64: "z.number().int()",
296
+ safeint: "z.number().int()",
297
+ uint8: "z.number().int()",
298
+ uint16: "z.number().int()",
299
+ uint32: "z.number().int()",
300
+ uint64: "z.number().int()",
301
+ integer: "z.number().int()",
302
+ float: "z.number()",
303
+ float32: "z.number()",
304
+ float64: "z.number()",
305
+ decimal: "z.number()",
306
+ decimal128: "z.number()",
307
+ numeric: "z.number()",
308
+ /**
309
+ * Dates cross the wire as strings and are validated as strings.
310
+ *
311
+ * Not `z.iso.datetime()`: response schemas are permissive on purpose, and a
312
+ * stricter format check here would turn a producer emitting a legal-but-unexpected instant into a
313
+ * failed response for a caller who could have parsed it perfectly well.
314
+ */
315
+ utcDateTime: "z.string()",
316
+ offsetDateTime: "z.string()",
317
+ plainDate: "z.string()",
318
+ plainTime: "z.string()",
319
+ duration: "z.string()",
320
+ url: "z.string()",
321
+ };
322
+ function scalarToZod(program, scalar) {
323
+ // `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
324
+ const encoded = encodedTypeOf(program, scalar);
325
+ if (encoded !== undefined && encoded !== scalar) {
326
+ return applyConstraints(program, scalarToZod(program, encoded), scalar);
327
+ }
328
+ let current = scalar;
329
+ // Walk the `extends` chain so a named scalar (`scalar chNumber extends string`) resolves to the
330
+ // primitive it is built on rather than being rejected for having a project-specific name.
331
+ while (current !== undefined) {
332
+ const mapped = SCALARS[current.name];
333
+ // Constraints are read from the scalar the property NAMED, not from the primitive it resolves
334
+ // to, so `@pattern(…) scalar TrimmedString extends string` carries its rules to every use.
335
+ if (mapped !== undefined)
336
+ return applyConstraints(program, mapped, scalar);
337
+ current = current.baseScalar;
338
+ }
339
+ reportDiagnostic(program, {
340
+ code: "unsupported-scalar",
341
+ target: scalar,
342
+ format: { artefact: "Zod", name: scalar.name },
343
+ });
344
+ return UNREPRESENTABLE;
345
+ }
346
+ /**
347
+ * A TypeSpec `enum` becomes a Zod enum over its member values, resolved through the generated
348
+ * vocabulary constant so the document and the runtime cannot describe different sets.
349
+ *
350
+ * ⚠️ **`@externalValues` used to be able to point that arrow the other way** — the spec deferring to
351
+ * a hand-written TypeScript tuple — and while it did, the members here described only the
352
+ * *document*. It is gone, and the tuples are generated from the spec.
353
+ */
354
+ function enumToZod(_program, target) {
355
+ const values = [...target.members.values()].map((member) => member.value ?? member.name);
356
+ if (values.some((value) => typeof value !== "string")) {
357
+ return `z.union([${values.map((value) => `z.literal(${JSON.stringify(value)})`).join(", ")}])`;
358
+ }
359
+ /**
360
+ * Every string vocabulary resolves through the generated constant rather than being restated.
361
+ *
362
+ * The members are declared once, in the spec, and the vocabularies artefact is generated from them —
363
+ * so the document and the runtime cannot describe different vocabularies. This replaces the
364
+ * `@externalValues` decorator, which pointed the arrow the other way: the spec deferred to a
365
+ * hand-written TypeScript constant, which is the opposite of a spec being a source of truth.
366
+ */
367
+ noteVocabulary(target.name, values);
368
+ /**
369
+ * Inline unless the consumer named a package to share the tuple through.
370
+ *
371
+ * The indirection exists so a SECOND package — another layer, a client — can branch on the same
372
+ * values without restating them. A consumer who has no such package wants a self-contained
373
+ * validator, and emitting a reference to one they never configured is the only way to get output
374
+ * that cannot resolve.
375
+ */
376
+ const shared = contractsPackage();
377
+ if (shared === undefined) {
378
+ return `z.enum([${values.map((value) => JSON.stringify(value)).join(", ")}])`;
379
+ }
380
+ noteExternalImport(shared, "SPEC_VOCABULARIES");
381
+ return `z.enum(SPEC_VOCABULARIES.${target.name})`;
382
+ }
383
+ /**
384
+ * A union becomes `z.enum` when every variant is a string literal, and `z.union` otherwise.
385
+ *
386
+ * `null` variants are lifted out into `.nullable()` rather than emitted as a member, because
387
+ * `z.union([z.string(), z.null()])` and `z.string().nullable()` differ in the error they produce and
388
+ * the latter is what a reader expects.
389
+ */
390
+ /**
391
+ * Register a declaration the SPEC never wrote, under a name the document publishes.
392
+ *
393
+ * A `@discriminated` union with an envelope has no TypeSpec type for its wrapper — openapi3
394
+ * synthesises one (`tk.model.create`, `schema-emitter.js:436`) and publishes it as
395
+ * `PetWithEnvelopeCat`. The registry keys declarations on a `Type`, and there is no type to key on,
396
+ * so this is the seam that lets one be declared by name instead. Falls back to inlining when no
397
+ * registry is installed, which keeps the property-level walks working.
398
+ */
399
+ let declareSynthetic;
400
+ export function withSyntheticDeclarations(hook, run) {
401
+ const previous = declareSynthetic;
402
+ declareSynthetic = hook;
403
+ try {
404
+ return run();
405
+ }
406
+ finally {
407
+ declareSynthetic = previous;
408
+ }
409
+ }
410
+ const capitalise = (value) => value.charAt(0).toUpperCase() + value.slice(1);
411
+ /** Whether a variant already carries the discriminator, under its declared or its wire name. */
412
+ function declaresDiscriminator(program, variant, property) {
413
+ if (variant.kind !== "Model")
414
+ return false;
415
+ return inheritedAndOwnProperties(variant).some((candidate) => candidate.name === property ||
416
+ resolveEncodedName(program, candidate, JSON_MEDIA_TYPE) === property);
417
+ }
418
+ /**
419
+ * One variant of a `@discriminated` union, as it appears ON THE WIRE.
420
+ *
421
+ * ⚠️ **The union is not a choice between its variant types — it is a choice between wrappings of
422
+ * them**, and this emitter used to ignore the wrapping entirely. `z.discriminatedUnion("kind",
423
+ * [catSchema, dogSchema])` over models that carry no `kind` at all **matches nothing**: every
424
+ * request to a polymorphic endpoint failed, and the differential could not see it because it read a
425
+ * discriminated component as an object.
426
+ *
427
+ * `envelope: "object"` (the default) wraps the variant — `{kind: "cat", value: {…Cat}}` — and
428
+ * openapi3 publishes each wrapper as a component named `union.type.name + capitalize(variantName)`.
429
+ * Mirrored exactly, so the identifiers line up with the component names.
430
+ *
431
+ * `envelope: "none"` injects the discriminator into the variant — `{kind: "cat", …Cat}` — and is
432
+ * emitted **only when the variant declares that property itself**. Otherwise the document does not
433
+ * publish it and the union is refused; see `undeclared-discriminator`.
434
+ */
435
+ function discriminatedVariant(program, union, variantName, variant, options) {
436
+ const inner = typeToZod(program, variant);
437
+ const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${JSON.stringify(variantName)})`;
438
+ // The variant already carries the discriminator, so the document publishes it and there is
439
+ // nothing to add.
440
+ if (options.envelope === "none")
441
+ return inner;
442
+ const wrapper = `${sealObjectSchemas ? "z.strictObject" : "z.object"}({ ${tag}, ${objectKey(options.envelopePropertyName)}: ${inner} })`;
443
+ const name = `${union.name ?? ""}${capitalise(variantName)}`;
444
+ if (union.name === undefined)
445
+ return wrapper;
446
+ return declareSynthetic?.(name, () => wrapper) ?? wrapper;
447
+ }
448
+ function unionToZod(program, union) {
449
+ /**
450
+ * A `@discriminated` union becomes `z.discriminatedUnion`, not `z.union`.
451
+ *
452
+ * Both accept the same values, but a plain union reports "no variant matched" and lists every
453
+ * failure, while a discriminated one reads the tag and reports the single rule that was actually
454
+ * broken. These unions exist to carry cross-field rules that used to hide in a hand-written
455
+ * predicate — publishing the rule and then reporting it unreadably would only move the problem.
456
+ */
457
+ const [discriminated] = getDiscriminatedUnion(program, union);
458
+ if (discriminated !== undefined) {
459
+ const options = discriminated.options;
460
+ if (discriminated.defaultVariant !== undefined) {
461
+ // "Anything else" has no discriminator value to switch on. Refused by name rather than
462
+ // silently dropped — no corpus scenario exercises it, so guessing would be untested code.
463
+ return refuse(program, union, "a discriminated union with a default variant");
464
+ }
465
+ /**
466
+ * ⚠️ **Refused, not compensated for.** With no envelope the discriminator lives inside the
467
+ * variant, and openapi3 publishes the variant unchanged — so unless the variant declares the
468
+ * property, the document names a discriminator it never requires, which OpenAPI 3.1 forbids.
469
+ * Injecting it here would enforce a rule the contract does not state: correct against the wire,
470
+ * derived from nothing anybody published, and indistinguishable from a bug to every other
471
+ * reader. A validator may only enforce what the document says.
472
+ */
473
+ if (options.envelope === "none") {
474
+ const undeclared = [...discriminated.variants.values()].filter((variant) => !declaresDiscriminator(program, variant, options.discriminatorPropertyName));
475
+ if (undeclared.length > 0) {
476
+ reportDiagnostic(program, {
477
+ code: "undeclared-discriminator",
478
+ target: union,
479
+ format: {
480
+ name: union.name ?? "an anonymous union",
481
+ property: options.discriminatorPropertyName,
482
+ variant: undeclared[0]?.name ?? "one of its variants",
483
+ },
484
+ });
485
+ return UNREPRESENTABLE;
486
+ }
487
+ }
488
+ const members = [...discriminated.variants.entries()].map(([name, variant]) => discriminatedVariant(program, union, name, variant, options));
489
+ const property = options.discriminatorPropertyName;
490
+ return `z.discriminatedUnion(${JSON.stringify(property)}, [${members.join(", ")}])`;
491
+ }
492
+ const variants = [...union.variants.values()].map((variant) => variant.type);
493
+ const nullable = variants.some((type) => type.kind === "Intrinsic" && type.name === "null");
494
+ const real = variants.filter((type) => !(type.kind === "Intrinsic" && type.name === "null"));
495
+ if (real.length === 0) {
496
+ reportDiagnostic(program, {
497
+ code: "empty-union",
498
+ target: union,
499
+ format: { why: "every variant is `null`" },
500
+ });
501
+ return UNREPRESENTABLE;
502
+ }
503
+ const suffix = nullable ? ".nullable()" : "";
504
+ if (real.every((type) => type.kind === "String")) {
505
+ const literals = real.map((type) => JSON.stringify(type.value));
506
+ return `z.enum([${literals.join(", ")}])${suffix}`;
507
+ }
508
+ if (real.length === 1)
509
+ return `${typeToZod(program, real[0])}${suffix}`;
510
+ return `z.union([${real.map((type) => typeToZod(program, type)).join(", ")}])${suffix}`;
511
+ }
512
+ /**
513
+ * TypeSpec models arrays and dictionaries as a `Model` carrying an **indexer**.
514
+ *
515
+ * ⚠️ **Keyed on the indexer, not on the model's name.** This used to test `name === "Record"`, which
516
+ * silently lost the indexer of any model *derived* from one — `model ProfileAttributes is
517
+ * Record<string | float64 | boolean>` emitted `z.object({})`, a schema that accepts an object and
518
+ * then strips every key in it. For the profile attributes that is silent data loss on a write, and
519
+ * nothing downstream could have told the difference between "the caller sent nothing" and "we threw
520
+ * it away". A named model is still declared and referenced normally; only its *body* comes from the
521
+ * indexer.
522
+ */
523
+ /** `unknown` — the indexer value of a `...Record<unknown>` spread, which is plain permissiveness. */
524
+ function isUnknownType(type) {
525
+ return type.kind === "Intrinsic" && type.name === "unknown";
526
+ }
527
+ /**
528
+ * Every property the model declares, **including the ones it inherits**.
529
+ *
530
+ * ⚠️ **`model.properties` holds only a model's OWN properties, and `baseModel` appeared nowhere in
531
+ * this emitter.** `model Extension extends Element` reaches the document as `allOf: [{$ref: Element}]`
532
+ * beside the derived model's own properties, and the sealing keyword openapi3 uses —
533
+ * `unevaluatedProperties` — is chosen precisely because it sees through that. So the document said
534
+ * `Extension` carries `level` **and** the inherited `extension`, while this emitter produced
535
+ * `z.object({level}).strict()`: the inherited property gone, and the model closed against it. The
536
+ * conformance scenario's own documented request body would have been rejected with a 400 naming a key
537
+ * the contract requires.
538
+ *
539
+ * Invisible for two reasons at once. The spec it was first built against declared no `extends` at
540
+ * all, and the differential
541
+ * skipped every schema carrying an `allOf` — so 28 of 226 components were not divergent, they were
542
+ * never compared.
543
+ *
544
+ * Flattened rather than composed. `z.object(...).extend(...)` would mirror the document's structure
545
+ * more literally and cannot be used here: `.extend()` reads `shape` eagerly, so a base whose property
546
+ * refers back to the derived model — `type/model/inheritance/recursive`, the exact case — would throw
547
+ * during module initialisation. Flattening asks nothing of the base but its properties.
548
+ */
549
+ export function inheritedAndOwnProperties(model) {
550
+ const chain = [];
551
+ for (let link = model; link !== undefined; link = link.baseModel) {
552
+ chain.unshift(link);
553
+ }
554
+ // Base first, so a derived model REDECLARING a property overrides rather than duplicates it.
555
+ const byName = new Map();
556
+ for (const link of chain) {
557
+ for (const property of link.properties.values())
558
+ byName.set(property.name, property);
559
+ }
560
+ return [...byName.values()];
561
+ }
562
+ /** The indexer in force, which a model can inherit — `model X extends Record<unknown>`. */
563
+ export function effectiveIndexer(model) {
564
+ for (let link = model; link !== undefined; link = link.baseModel) {
565
+ if (link.indexer !== undefined)
566
+ return link.indexer;
567
+ }
568
+ return undefined;
569
+ }
570
+ /**
571
+ * The subtypes a `@discriminator` base stands for, in declaration order.
572
+ *
573
+ * Filtered by openapi3's own {@link includeDerivedModel} so a template declaration is not mistaken
574
+ * for a real subtype — the same rule that decides whether a base may be sealed.
575
+ */
576
+ export function discriminatedSubtypes(program, model) {
577
+ if (getDiscriminator(program, model) === undefined)
578
+ return [];
579
+ return model.derivedModels.filter((derived) => includeDerivedModel(derived));
580
+ }
581
+ function modelToZod(program, model) {
582
+ /**
583
+ * ⚠️ **A `@discriminator` base is a CHOICE between its subtypes, not a shape of its own.**
584
+ *
585
+ * `@discriminator("kind") model Bird { kind: string; wingspan: int32 }` with four models extending
586
+ * it reaches OpenAPI as a component carrying `discriminator: {propertyName, mapping}`, and the
587
+ * mapping is an instruction: validate the body against whichever subtype the discriminator names.
588
+ * Emitting the base's own properties instead produced `z.object({kind: z.string(), wingspan})`,
589
+ * which accepts `{kind: "eagle"}` while ignoring everything `Eagle` actually declares — a
590
+ * polymorphic endpoint validating none of its polymorphism.
591
+ *
592
+ * `z.discriminatedUnion` is the faithful encoding and composes both ways this corpus needs.
593
+ * Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
594
+ * `Fish → Shark → SawShark` works; and an option carrying a getter that refers back to the union
595
+ * being defined does **not** throw, because the discriminator is read without enumerating the rest
596
+ * of the shape. `Eagle.friends?: Bird[]` is exactly that, and it decides whether polymorphism and
597
+ * recursion can coexist at all.
598
+ */
599
+ const subtypes = discriminatedSubtypes(program, model);
600
+ if (subtypes.length > 0) {
601
+ const discriminator = getDiscriminator(program, model);
602
+ const options = subtypes.map((derived) => typeToZod(program, derived));
603
+ return `z.discriminatedUnion(${JSON.stringify(discriminator?.propertyName)}, [${options.join(", ")}])`;
604
+ }
605
+ const indexer = effectiveIndexer(model);
606
+ const indexerKey = indexer?.key.name;
607
+ const declared = inheritedAndOwnProperties(model);
608
+ if (indexerKey === "integer" && indexer !== undefined) {
609
+ return applyConstraints(program, `z.array(${typeToZod(program, indexer.value)})`, model);
610
+ }
611
+ /**
612
+ * ⚠️ **A string indexer only means "dictionary" when the model declares NO properties.**
613
+ *
614
+ * `model X { id: string; ...Record<unknown> }` — the standard TypeSpec way to say "these fields,
615
+ * plus anything else", and what `@loose` was replaced by — carries both. Returning
616
+ * `z.record(...)` for it emits a schema that accepts an object and then **strips every declared
617
+ * field**, so `respond()`'s drift detection degrades to "is it an object" and the pinned fields
618
+ * silently stop being checked. This function's own history is the other half of the same mistake:
619
+ * it once keyed on the model's *name*, and lost the indexer of anything derived from `Record`.
620
+ */
621
+ if (indexerKey === "string" && indexer !== undefined && declared.length === 0) {
622
+ return applyConstraints(program, `z.record(z.string(), ${typeToZod(program, indexer.value)})`, model);
623
+ }
624
+ /**
625
+ * Permissiveness, read from the INDEXER and from nothing else.
626
+ *
627
+ * `...Record<unknown>` is the standard TypeSpec construct and the one `@typespec/openapi3` can
628
+ * see, so it is the only source. There was a second — `@loose`, a decorator of ours, invisible to
629
+ * the document — and it is gone: a model that accepted unknown properties while the published
630
+ * schema said otherwise is a runtime disagreeing with its own contract.
631
+ *
632
+ * An indexer over something other than `unknown` is a typed catchall, where `.loose()` would drop
633
+ * that value type on the floor.
634
+ */
635
+ const indexerValue = indexerKey === "string" ? indexer?.value : undefined;
636
+ const suffix = indexerValue !== undefined && isUnknownType(indexerValue)
637
+ ? ".loose()"
638
+ : indexerValue === undefined
639
+ ? ""
640
+ : `.catchall(${typeToZod(program, indexerValue)})`;
641
+ /**
642
+ * A request model REJECTS an unrecognised field rather than stripping it.
643
+ *
644
+ * Silently dropping something a caller sent is the same failure this whole surface exists to
645
+ * remove, one layer out: the request succeeds and does less than it was asked to, with nothing
646
+ * anywhere reporting it. `appointedOn` went missing for exactly that reason and surfaced as a
647
+ * confusing downstream 400 instead of a loud one naming the key.
648
+ */
649
+ const strict = suffix === "" && isSealed(model) ? ".strict()" : "";
650
+ /**
651
+ * ⚠️ **HTTP metadata is not payload.** A `@header` or `@statusCode` property describes the envelope,
652
+ * so it never appears in the body — `@typespec/openapi3` strips it from the schema it emits, and a
653
+ * validator that kept it would demand a field the wire never carries.
654
+ *
655
+ * Found the moment a model carried its own content-type: RFC 9457 puts
656
+ * `@header("content-type") contentType: "application/problem+json"` on the problem model, the
657
+ * document correctly described a body without it, and this emitter produced
658
+ * `contentType: z.literal(…)` as a required member. Two artefacts from one source, disagreeing
659
+ * about the shape of a body — the exact contradiction ADR 0005 exists to make impossible.
660
+ */
661
+ /**
662
+ * ⚠️ **A property that refers back into an unfinished declaration is emitted as a GETTER.**
663
+ *
664
+ * `model InnerModel { children?: InnerModel[] }` is a plain recursive tree, and openapi3 publishes
665
+ * it as a self-`$ref`. Emitting `children: z.array(innerModelSchema)` reads the `const` inside its
666
+ * own initialiser, so the module throws on import; deferring the reference behind a getter is the
667
+ * idiom Zod documents for exactly this, and it is the only one that keeps `z.infer` inferring the
668
+ * recursive type rather than collapsing to `any`. `z.lazy(() => innerModelSchema)` runs correctly
669
+ * but does not typecheck — measured: `TS7022`, "implicitly has type 'any' because it does not have
670
+ * a type annotation and is referenced directly or indirectly in its own initializer".
671
+ */
672
+ const members = declared
673
+ .filter((property) => isPayloadProperty(program, property))
674
+ .map((property) => {
675
+ const key = propertyKey(program, property);
676
+ const { value, deferred } = captureBackEdges(() => propertyToZod(program, property));
677
+ return {
678
+ deferred,
679
+ text: deferred ? `\tget ${key}() {\n\t\treturn ${value};\n\t},` : `\t${key}: ${value},`,
680
+ };
681
+ });
682
+ const entries = members.map((member) => member.text);
683
+ /**
684
+ * A model-level `@refine` goes through the same path as every other constraint, so the predicate's
685
+ * import is recorded exactly once, in one place. Handling it separately here is what dropped the
686
+ * import the first time — the emitted schema referenced a name it never imported, and only the
687
+ * compile check in `test/emit.test.ts` noticed.
688
+ *
689
+ * `.refine()` returns an effect rather than an object, so `applyConstraints` appending it after
690
+ * `.loose()` is also the only order that works.
691
+ */
692
+ /**
693
+ * ⚠️ **A deferred property forces the CONSTRUCTOR form of the object.**
694
+ *
695
+ * Measured on Zod 4.4.3: `.strict()`, `.loose()` and `.catchall()` all read `shape` eagerly, so
696
+ * each one fires the getter during module initialisation and throws `Cannot access 'X' before
697
+ * initialization` — the very thing the getter exists to avoid. `z.strictObject` and
698
+ * `z.looseObject` take the same shape without reading it. The suffix form stays everywhere else,
699
+ * so a model that does not recurse emits exactly the text it emitted before.
700
+ *
701
+ * A **typed** catchall has no constructor form to move to, so there is nowhere safe to put the
702
+ * getter and the cycle is genuinely unrepresentable here — that is a refusal, and it is named.
703
+ */
704
+ const lazily = members.some((member) => member.deferred);
705
+ const constructible = suffix === "" || suffix === ".loose()";
706
+ if (lazily && !constructible) {
707
+ reportDiagnostic(program, {
708
+ code: "circular-model",
709
+ target: model,
710
+ format: { name: model.name === "" ? "an anonymous model" : model.name },
711
+ });
712
+ }
713
+ const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
714
+ const body = lazily && constructible
715
+ ? `${suffix === ".loose()" ? "z.looseObject" : strict === ".strict()" ? "z.strictObject" : "z.object"}(${shape})`
716
+ : `z.object(${shape})${suffix}${strict}`;
717
+ return applyConstraints(program, body, model);
718
+ }
719
+ /**
720
+ * A name as an object key — quoted when it is not a legal JavaScript identifier.
721
+ *
722
+ * ⚠️ **A TypeSpec property can be named anything**, and the corpus proves it: `parameters/spread`
723
+ * declares `` `x-ms-test-header`: string ``. A parameter walk assumed the property name was always
724
+ * safe — its docblock even argued that using the property name rather than the wire name was what
725
+ * avoided this — and emitted `x-ms-test-header: z.string()`, a file that does not parse.
726
+ *
727
+ * Exported so there is one answer to this question. There were two sites and only one of them knew.
728
+ */
729
+ export function objectKey(name) {
730
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
731
+ }
732
+ /**
733
+ * The name a property carries **on the wire**, which is not always the name the spec gave it.
734
+ *
735
+ * ⚠️ **`@encodedName` is a first-party decorator, and a decorator is a declaration rather than a
736
+ * behaviour.** It attaches metadata to the type graph and does nothing on its own; every emitter has
737
+ * to go and ask. `@typespec/openapi3` asks — `resolveEncodedName` at `schema-emitter.js:214` — so the
738
+ * document publishes the wire name. This emitter did not, so the validator was keyed on the TypeSpec
739
+ * name and a request carrying exactly what the contract required failed twice over: the declared
740
+ * property was missing, and the one actually sent was an unrecognised key a sealed model rejects.
741
+ *
742
+ * The corpus scenario for this is called "Testing that you send the right JSON name on the wire",
743
+ * and it sat in the green column throughout — compiling cleanly while emitting the wrong key.
744
+ *
745
+ * **The domain therefore sees the wire name too**, because `types.ts` resolves it identically. A
746
+ * validator that renamed on the way through would be a transform, which JSON Schema cannot express
747
+ * and no document could state — the same reason `@trimmed` was removed.
748
+ */
749
+ function propertyKey(program, property) {
750
+ return objectKey(rawPropertyKey(program, property));
751
+ }
752
+ /** The same wire name, unquoted — for a vocabulary tuple, where the value is data rather than code. */
753
+ function rawPropertyKey(program, property) {
754
+ return resolveEncodedName(program, property, JSON_MEDIA_TYPE);
755
+ }
756
+ /**
757
+ * The media type wire names are resolved for.
758
+ *
759
+ * Fixed rather than threaded: everything this emitter produces validates a JSON body, and
760
+ * `@encodedName` is per-media-type. A surface serving XML as well would need this carried from the
761
+ * operation's content type, as openapi3 does — and would need far more than a name to be correct.
762
+ */
763
+ const JSON_MEDIA_TYPE = "application/json";
764
+ /**
765
+ * The type a value takes **on the wire**, which `@encode` can make different from its declared one.
766
+ *
767
+ * ⚠️ **A first-party decorator, and a decorator is a declaration rather than a behaviour** — every
768
+ * emitter has to go and ask. `@typespec/openapi3` asks, so the document publishes the encoded type;
769
+ * this emitter did not, and validated the DECLARED one. Measured across the corpus, 30 properties
770
+ * where the two disagreed, and each disagreement rejects a body the contract calls valid:
771
+ *
772
+ * `@encode("csv") string[]` document `string` we emitted an array
773
+ * `@encode(seconds, float) duration` document `number` we emitted a string
774
+ * `@encode(string) int32` document `string` we emitted a number
775
+ * `@encode(unixTimestamp) utcDateTime` document `integer` we emitted a string
776
+ *
777
+ * Exactly the shape of the `@encodedName` defect, found by exactly the same kind of arm — there, the
778
+ * key a property goes by; here, the type it goes as.
779
+ *
780
+ * ⚠️ **An array encoding on a QUERY parameter is a style, not a type change — and this docblock used
781
+ * to say the opposite.** It claimed the array encodings needed no special case "because a
782
+ * comma-delimited list IS a string on the wire". True of the wire, and the wrong conclusion: the
783
+ * document keeps `type: array` and publishes `style: pipeDelimited` beside it, because the parameter
784
+ * IS a list and the style says how it was flattened. Emitting `z.string()` made the validator
785
+ * disagree with the contract and gave the handler a string where the operation declared `string[]`.
786
+ *
787
+ * The rule is openapi3's own, from `applyEncoding`:
788
+ *
789
+ * ```js
790
+ * if (isQueryParam(program, typespecType) && isParameterStyleEncoding(encodeData.encoding)) {
791
+ * return targetObject; // the type is left alone
792
+ * }
793
+ * ```
794
+ *
795
+ * Everywhere else the encoding really does change the type — `@encode(unixTimestamp) utcDateTime` is
796
+ * an integer — so this narrows to exactly the case the reference implementation narrows to. The
797
+ * flattening is undone at the parameter, by {@link COLLECTION_DELIMITERS}.
798
+ */
799
+ const PARAMETER_STYLE_ENCODINGS = new Set([
800
+ "ArrayEncoding.pipeDelimited",
801
+ "ArrayEncoding.spaceDelimited",
802
+ "ArrayEncoding.commaDelimited",
803
+ "ArrayEncoding.newlineDelimited",
804
+ ]);
805
+ function encodedTypeOf(program, target) {
806
+ const encoded = getEncode(program, target);
807
+ if (encoded === undefined)
808
+ return undefined;
809
+ if (target.kind === "ModelProperty" &&
810
+ isQueryParam(program, target) &&
811
+ PARAMETER_STYLE_ENCODINGS.has(encoded.encoding ?? "")) {
812
+ return undefined;
813
+ }
814
+ return encoded.type;
815
+ }
816
+ export function propertyToZod(program, property) {
817
+ /**
818
+ * ⚠️ **Constraints go INSIDE `.nullable()`, not after it.**
819
+ *
820
+ * `z.string().nullable().min(1)` does not compile — `.min` is not on `ZodNullable` — so a property
821
+ * typed `string | null` with `@minLength(1)` has to emit `z.string().min(1).nullable()`. The
822
+ * nullability is peeled off here, the constraints applied to what is underneath, and the wrapper
823
+ * put back. Found by a consumer's own typecheck on a nullable-and-constrained property.
824
+ *
825
+ * `.optional()` stays outermost: a constraint after it would apply to the optionality rather than
826
+ * to the value.
827
+ */
828
+ const nullable = nullableInnerOf(property.type);
829
+ // `@encode` on the property wins over its declared type — see `encodedTypeOf`. The nullable
830
+ // wrapper is kept: `@encode` changes what the value IS, not whether it may be absent.
831
+ const encoded = encodedTypeOf(program, property);
832
+ const declared = encoded ?? nullable ?? property.type;
833
+ const base = nullable === undefined || encoded !== undefined
834
+ ? applyConstraints(program, typeToZod(program, declared), property)
835
+ : `${applyConstraints(program, typeToZod(program, declared), property)}.nullable()`;
836
+ /**
837
+ * A TypeSpec default (`basis: string = "s1159"`) becomes `.default(…)`, which in Zod also makes
838
+ * the field optional on the way in. Dropping it turned an optional field with a fallback into a
839
+ * required one — callers omitting it would start getting a 400 for a request that used to work.
840
+ */
841
+ const defaulted = defaultOf(program, property);
842
+ if (defaulted !== undefined)
843
+ return `${base}.default(${defaulted})`;
844
+ /**
845
+ * ⚠️ **Optionality is a function of the position too, not only of the `?` in the spec.**
846
+ *
847
+ * TypeSpec's PATCH semantics make a property optional in an update request even where the model
848
+ * declares it required — an update sends the fields it is changing. The document says so
849
+ * (`VisibilityModelUpdate` requires nothing) and a validator that still demanded the property
850
+ * would reject every partial update the contract invites. `metadataInfo.isOptional` is the same
851
+ * predicate `@typespec/openapi3` uses to decide the `required` list.
852
+ */
853
+ return property.optional || isOptionalAt(program, property) ? `${base}.optional()` : base;
854
+ }
855
+ /** A property's declared default, as a JS literal, or `undefined` when it has none. */
856
+ function defaultOf(program, property) {
857
+ const value = property.defaultValue;
858
+ if (value === undefined)
859
+ return undefined;
860
+ if (value.valueKind === "StringValue")
861
+ return JSON.stringify(value.value);
862
+ if (value.valueKind === "NumericValue")
863
+ return String(value.value.asNumber());
864
+ if (value.valueKind === "BooleanValue")
865
+ return String(value.value);
866
+ if (value.valueKind === "EnumValue") {
867
+ const member = value.value;
868
+ return JSON.stringify(member.value ?? member.name);
869
+ }
870
+ // `attendees?: string[] = #[]` — an empty-collection default, emitted as `.default([])`.
871
+ // Only empty ones: a populated literal default would need each element rendered, and no schema here
872
+ // has one, so it stays unsupported rather than half-implemented.
873
+ if (value.valueKind === "ArrayValue") {
874
+ const values = value.values ?? [];
875
+ if (values.length === 0)
876
+ return "[]";
877
+ reportDiagnostic(program, {
878
+ code: "unsupported-default",
879
+ target: property,
880
+ format: { why: "a non-empty array" },
881
+ });
882
+ return UNREPRESENTABLE;
883
+ }
884
+ if (value.valueKind === "ObjectValue") {
885
+ const properties = value.properties;
886
+ if (properties === undefined || properties.size === 0)
887
+ return "{}";
888
+ reportDiagnostic(program, {
889
+ code: "unsupported-default",
890
+ target: property,
891
+ format: { why: "a non-empty object" },
892
+ });
893
+ return UNREPRESENTABLE;
894
+ }
895
+ reportDiagnostic(program, {
896
+ code: "unsupported-default",
897
+ target: property,
898
+ format: { why: `kind "${value.valueKind}"` },
899
+ });
900
+ return UNREPRESENTABLE;
901
+ }
902
+ /** The non-null half of a two-variant `T | null` union, or `undefined` if it is not one. */
903
+ function nullableInnerOf(type) {
904
+ if (type.kind !== "Union")
905
+ return undefined;
906
+ const variants = [...type.variants.values()].map((variant) => variant.type);
907
+ const real = variants.filter((v) => !(v.kind === "Intrinsic" && v.name === "null"));
908
+ if (real.length !== variants.length - 1 || real.length !== 1)
909
+ return undefined;
910
+ return real[0];
911
+ }
912
+ /**
913
+ * Resolves a type to the identifier of an already-declared schema, or `undefined` to inline it.
914
+ *
915
+ * The registry installs this so a named model is emitted once and referenced everywhere else. Kept
916
+ * as module state rather than threaded through every call because `typeToZod` recurses through
917
+ * unions, arrays and properties, and an extra parameter on each would be carried by every case for
918
+ * the benefit of two.
919
+ */
920
+ let resolveRef = () => undefined;
921
+ export function withRefResolver(resolver, run) {
922
+ const previous = resolveRef;
923
+ resolveRef = resolver;
924
+ try {
925
+ return run();
926
+ }
927
+ finally {
928
+ resolveRef = previous;
929
+ }
930
+ }
931
+ /**
932
+ * How many references the current walk has made **back into a declaration still being rendered**.
933
+ *
934
+ * A recursive model is an ordinary construct — `@typespec/openapi3` emits a self-`$ref` for
935
+ * `model InnerModel { children?: InnerModel[] }` and the document is valid — so the reference has to
936
+ * be emitted, not refused. But a `const` cannot read itself while it is being initialised, so the
937
+ * reference must be made **lazy**, and only the enclosing object knows where to put the getter that
938
+ * does it. The registry counts the back edges; {@link captureBackEdges} is how an object claims them.
939
+ */
940
+ let backEdges = 0;
941
+ /** Called by the registry when it hands out the identifier of a declaration that is still open. */
942
+ export function noteBackEdge() {
943
+ backEdges += 1;
944
+ }
945
+ /**
946
+ * Render `run()`, reporting whether it reached back into a declaration still being rendered.
947
+ *
948
+ * ⚠️ **The count is restored on the way out, and that is the point.** A caller that wraps the
949
+ * reference in a getter has *settled* the back edge — the reference is now lazy and the enclosing
950
+ * object is safe. Letting it propagate would make every ancestor defer as well, and would make the
951
+ * registry report a cycle that has already been dealt with.
952
+ */
953
+ export function captureBackEdges(run) {
954
+ const before = backEdges;
955
+ const value = run();
956
+ const deferred = backEdges > before;
957
+ backEdges = before;
958
+ return { value, deferred };
959
+ }
960
+ export function typeToZod(program, type) {
961
+ const ref = resolveRef(type);
962
+ if (ref !== undefined)
963
+ return ref;
964
+ return typeToZodBody(program, type);
965
+ }
966
+ /**
967
+ * The same walk, except that `type` is never resolved to its own identifier.
968
+ *
969
+ * The registry renders a declaration's *body* with this: asking the resolver first would hand back
970
+ * the identifier the declaration is about to bind, emitting `const xSchema = xSchema`. Nested
971
+ * occurrences still go through {@link typeToZod} and do resolve — which is what makes a recursive
972
+ * model reference itself instead of inlining itself forever.
973
+ */
974
+ export function typeToZodBody(program, type) {
975
+ switch (type.kind) {
976
+ case "Scalar":
977
+ return scalarToZod(program, type);
978
+ case "Model":
979
+ return modelToZod(program, type);
980
+ case "Enum":
981
+ return enumToZod(program, type);
982
+ case "Union":
983
+ return unionToZod(program, type);
984
+ case "String":
985
+ return `z.literal(${JSON.stringify(type.value)})`;
986
+ case "Number":
987
+ return `z.literal(${type.value})`;
988
+ case "Boolean":
989
+ return `z.literal(${String(type.value)})`;
990
+ case "EnumMember":
991
+ return `z.literal(${JSON.stringify(type.value ?? type.name)})`;
992
+ /**
993
+ * ⚠️ **A named union VARIANT is a type you can refer to, and it is not the union.**
994
+ *
995
+ * `union DogKind { string, Golden: "golden" }` is TypeSpec's extensible-enum idiom, and
996
+ * `kind: DogKind.Golden` resolves to the variant rather than to its value. Unreachable until
997
+ * discriminated subtypes started being declared, at which point the whole
998
+ * `type/model/inheritance/enum-discriminator` scenario stopped compiling with
999
+ * "no rule for this kind" — a dormant seam, not a new defect.
1000
+ */
1001
+ case "UnionVariant":
1002
+ return typeToZod(program, type.type);
1003
+ case "Intrinsic":
1004
+ if (type.name === "null")
1005
+ return "z.null()";
1006
+ if (type.name === "unknown")
1007
+ return "z.unknown()";
1008
+ if (type.name === "void" || type.name === "never") {
1009
+ return refuse(program, type, `\`${type.name}\` has no runtime representation`);
1010
+ }
1011
+ return refuse(program, type, `intrinsic "${type.name}"`);
1012
+ default:
1013
+ return refuse(program, type, "no rule for this kind");
1014
+ }
1015
+ }