typespec-http-zod 0.28.0 → 0.30.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.
package/dist/src/zod.d.ts CHANGED
@@ -75,6 +75,50 @@ export declare function withDeclarationSite<T>(site: Type | undefined, run: () =
75
75
  * walked, which does.
76
76
  */
77
77
  export declare function refusalTarget(type: Type): Type;
78
+ /**
79
+ * **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
80
+ *
81
+ * `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
82
+ * into the validators: a surface publishing its input schema FROM them - an MCP server's
83
+ * `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
84
+ * for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
85
+ * the client checking that published schema before it called.
86
+ *
87
+ * `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
88
+ * every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
89
+ * it by default. It returns a clone and changes nothing a parse decides.
90
+ *
91
+ * **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
92
+ * `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
93
+ * `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
94
+ * applied where a declaration's body, a property's value or a scalar's schema is finished, and
95
+ * nowhere else.
96
+ *
97
+ * Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
98
+ * `@doc("")` is published as `""`, and so is it here.
99
+ */
100
+ export declare function described(expression: string, doc: string | undefined): string;
101
+ /**
102
+ * The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
103
+ * chain, and never a standard type's.
104
+ *
105
+ * Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
106
+ * schema, description included, and overwrites what it declares itself - so `scalar B extends A`
107
+ * with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
108
+ * going to be inlined in the spec and we don't want the description of the scalar to show up".
109
+ * Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
110
+ */
111
+ export declare function scalarDocOf(program: Program, scalar: Scalar): string | undefined;
112
+ /** A scalar's schema without its description, for a position that publishes its own. */
113
+ /**
114
+ * The scalars an `@encode` chain passes through, when it returns to one it already passed - and
115
+ * `undefined` when it ends at a type that crosses the wire.
116
+ *
117
+ * **The one guard for every walk that follows `@encode`.** The schema, the wire kind and the contract
118
+ * type each follow the chain by calling themselves on the encoded type, and each one overflowed the
119
+ * stack on `Ping -> Pong -> Ping`. A scalar encoded as itself is no cycle: the chain ends there.
120
+ */
121
+ export declare function encodeCycleOf(program: Program, scalar: Scalar): readonly Scalar[] | undefined;
78
122
  export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
79
123
  /**
80
124
  * Every property the model declares, **including the ones it inherits**.
@@ -108,6 +152,40 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
108
152
  * for a real subtype - the same rule that decides whether a base may be sealed.
109
153
  */
110
154
  export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
155
+ /**
156
+ * **How a `@discriminator` base is validated: a value whose discriminator names a subtype as that
157
+ * subtype, and any other value as the base model itself.**
158
+ *
159
+ * The document publishes the base as an object of its own properties carrying `discriminator:
160
+ * {propertyName, mapping}`, and every subtype as `allOf` that base. The mapping says which subtype a
161
+ * value naming it is; nothing in the document refuses a value naming none. This emitter dispatched
162
+ * on the mapping and stopped there, so `{ "kind": "wrongKind", "weight": 8 }` was refused for a
163
+ * `Dog` whose `kind` is `string | "golden"`, though `Dog` requires only `kind` and `weight`. That is
164
+ * the response `@typespec/http-specs` declares for `type/model/inheritance/enum-discriminator`,
165
+ * `single-discriminator` and `nested-discriminator`, and a handler returning it failed its own
166
+ * response contract (typespec-http-graphql, 2026-10-16).
167
+ *
168
+ * **A closed discriminator every value of which a subtype claims has no base arm**: a value outside
169
+ * it is refused by the base's own discriminator, and a value inside it always names a subtype. So a
170
+ * fixed enum fully covered by its subtypes is validated exactly as before.
171
+ */
172
+ export interface DiscriminatedBase {
173
+ /** The discriminator's wire name. */
174
+ readonly propertyName: string;
175
+ /** The subtypes a `z.discriminatedUnion` dispatches to, each carrying literal discriminator values. */
176
+ readonly dispatched: readonly Model[];
177
+ /**
178
+ * Subtypes that are themselves bases validated with a base arm. A `z.union` has no literal values
179
+ * to dispatch on, so no `z.discriminatedUnion` can hold one; it sits beside the dispatch, where its
180
+ * own literal discriminator keeps it exclusive.
181
+ */
182
+ readonly beside: readonly Model[];
183
+ /** Every value a subtype claims, which are the keys of the document's `mapping`. */
184
+ readonly claimed: readonly string[];
185
+ /** Whether a value no subtype claims is validated as the base model. */
186
+ readonly open: boolean;
187
+ }
188
+ export declare function discriminatedBaseOf(program: Program, model: Model): DiscriminatedBase | undefined;
111
189
  export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
112
190
  /**
113
191
  * A name as an object key - quoted when it is not a legal JavaScript identifier.
package/dist/src/zod.js CHANGED
@@ -1,6 +1,7 @@
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";
1
+ import { getDiscriminatedUnion, getDiscriminatedUnionFromInheritance, getDiscriminator, getDoc, getEncode, ignoreDiagnostics, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
2
+ import { createMetadataInfo, getCookieParamOptions, getHeaderFieldName, getPathParamName, getQueryParamName, isCookieParam, isHeader, isPathParam, isQueryParam, Visibility, } from "@typespec/http";
3
+ import { applyConstraints, firstReportOf } from "./constraints.js";
4
+ import { literal } from "./literal.js";
4
5
  import { reportDiagnostic } from "./lib.js";
5
6
  /**
6
7
  * What a walk returns after reporting a refusal.
@@ -406,10 +407,95 @@ const SCALARS = {
406
407
  url: "z.url()",
407
408
  };
408
409
  function scalarToZod(program, scalar) {
410
+ return described(scalarSchemaOf(program, scalar), scalarDocOf(program, scalar));
411
+ }
412
+ /**
413
+ * **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
414
+ *
415
+ * `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
416
+ * into the validators: a surface publishing its input schema FROM them - an MCP server's
417
+ * `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
418
+ * for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
419
+ * the client checking that published schema before it called.
420
+ *
421
+ * `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
422
+ * every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
423
+ * it by default. It returns a clone and changes nothing a parse decides.
424
+ *
425
+ * **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
426
+ * `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
427
+ * `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
428
+ * applied where a declaration's body, a property's value or a scalar's schema is finished, and
429
+ * nowhere else.
430
+ *
431
+ * Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
432
+ * `@doc("")` is published as `""`, and so is it here.
433
+ */
434
+ export function described(expression, doc) {
435
+ return doc === undefined ? expression : `${expression}.describe(${literal(doc)})`;
436
+ }
437
+ /**
438
+ * The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
439
+ * chain, and never a standard type's.
440
+ *
441
+ * Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
442
+ * schema, description included, and overwrites what it declares itself - so `scalar B extends A`
443
+ * with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
444
+ * going to be inlined in the spec and we don't want the description of the scalar to show up".
445
+ * Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
446
+ */
447
+ export function scalarDocOf(program, scalar) {
448
+ for (let current = scalar; current !== undefined && !program.checker.isStdType(current); current = current.baseScalar) {
449
+ const doc = getDoc(program, current);
450
+ if (doc !== undefined)
451
+ return doc;
452
+ }
453
+ return undefined;
454
+ }
455
+ /** A scalar's schema without its description, for a position that publishes its own. */
456
+ /**
457
+ * The scalars an `@encode` chain passes through, when it returns to one it already passed - and
458
+ * `undefined` when it ends at a type that crosses the wire.
459
+ *
460
+ * **The one guard for every walk that follows `@encode`.** The schema, the wire kind and the contract
461
+ * type each follow the chain by calling themselves on the encoded type, and each one overflowed the
462
+ * stack on `Ping -> Pong -> Ping`. A scalar encoded as itself is no cycle: the chain ends there.
463
+ */
464
+ export function encodeCycleOf(program, scalar) {
465
+ const passed = [];
466
+ for (let current = scalar; current !== undefined;) {
467
+ if (passed.includes(current))
468
+ return [...passed, current];
469
+ passed.push(current);
470
+ const encoded = encodedTypeOf(program, current);
471
+ current = encoded === current ? undefined : encoded;
472
+ }
473
+ return undefined;
474
+ }
475
+ function scalarSchemaOf(program, scalar) {
409
476
  // `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
410
477
  const encoded = encodedTypeOf(program, scalar);
411
478
  if (encoded !== undefined && encoded !== scalar) {
412
- return applyConstraints(program, scalarToZod(program, encoded), scalar);
479
+ const cycle = encodeCycleOf(program, scalar);
480
+ if (cycle !== undefined) {
481
+ // Reached once per use; reported once per scalar.
482
+ if (firstReportOf(program, "encode-cycle", scalar)) {
483
+ reportDiagnostic(program, {
484
+ code: "encode-cycle",
485
+ target: scalar,
486
+ format: {
487
+ type: scalar.name,
488
+ // From the first link on: `Ping` is encoded as `'Pong' -> 'Ping'`.
489
+ chain: cycle
490
+ .slice(1)
491
+ .map((link) => `'${link.name}'`)
492
+ .join(" -> "),
493
+ },
494
+ });
495
+ }
496
+ return UNREPRESENTABLE;
497
+ }
498
+ return applyConstraints(program, scalarSchemaOf(program, encoded), scalar);
413
499
  }
414
500
  let current = scalar;
415
501
  // Walk the `extends` chain so a named scalar (`scalar chNumber extends string`) resolves to the
@@ -454,7 +540,7 @@ function scalarToZod(program, scalar) {
454
540
  function enumToZod(_program, target) {
455
541
  const values = [...target.members.values()].map((member) => member.value ?? member.name);
456
542
  if (values.some((value) => typeof value !== "string")) {
457
- return `z.union([${values.map((value) => `z.literal(${JSON.stringify(value)})`).join(", ")}])`;
543
+ return `z.union([${values.map((value) => `z.literal(${literal(value)})`).join(", ")}])`;
458
544
  }
459
545
  /**
460
546
  * Every string vocabulary resolves through the generated constant rather than being restated.
@@ -475,7 +561,7 @@ function enumToZod(_program, target) {
475
561
  */
476
562
  const shared = contractsPackage();
477
563
  if (shared === undefined) {
478
- return `z.enum([${values.map((value) => JSON.stringify(value)).join(", ")}])`;
564
+ return `z.enum([${values.map((value) => literal(value)).join(", ")}])`;
479
565
  }
480
566
  noteExternalImport(shared, "SPEC_VOCABULARIES");
481
567
  return `z.enum(SPEC_VOCABULARIES.${target.name})`;
@@ -534,7 +620,7 @@ function declaresDiscriminator(program, variant, property) {
534
620
  */
535
621
  function discriminatedVariant(program, union, variantName, variant, options) {
536
622
  const inner = typeToZod(program, variant);
537
- const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${JSON.stringify(variantName)})`;
623
+ const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${literal(variantName)})`;
538
624
  // The variant already carries the discriminator, so the document publishes it and there is
539
625
  // nothing to add.
540
626
  if (options.envelope === "none")
@@ -587,7 +673,7 @@ function unionToZod(program, union) {
587
673
  }
588
674
  const members = [...discriminated.variants.entries()].map(([name, variant]) => discriminatedVariant(program, union, name, variant, options));
589
675
  const property = options.discriminatorPropertyName;
590
- return `z.discriminatedUnion(${JSON.stringify(property)}, [${members.join(", ")}])`;
676
+ return `z.discriminatedUnion(${literal(property)}, [${members.join(", ")}])`;
591
677
  }
592
678
  const variants = [...union.variants.values()].map((variant) => variant.type);
593
679
  const nullable = variants.some((type) => type.kind === "Intrinsic" && type.name === "null");
@@ -602,7 +688,7 @@ function unionToZod(program, union) {
602
688
  }
603
689
  const suffix = nullable ? ".nullable()" : "";
604
690
  if (real.every((type) => type.kind === "String")) {
605
- const literals = real.map((type) => JSON.stringify(type.value));
691
+ const literals = real.map((type) => literal(type.value));
606
692
  return `z.enum([${literals.join(", ")}])${suffix}`;
607
693
  }
608
694
  if (real.length === 1)
@@ -678,30 +764,130 @@ export function discriminatedSubtypes(program, model) {
678
764
  return [];
679
765
  return model.derivedModels.filter((derived) => includeDerivedModel(derived));
680
766
  }
767
+ export function discriminatedBaseOf(program, model) {
768
+ const discriminator = getDiscriminator(program, model);
769
+ const subtypes = discriminatedSubtypes(program, model);
770
+ if (discriminator === undefined || subtypes.length === 0)
771
+ return undefined;
772
+ /**
773
+ * The compiler's own reading of which subtype claims which value, which is what openapi3 writes
774
+ * the `mapping` from.
775
+ */
776
+ const claimed = [
777
+ ...ignoreDiagnostics(getDiscriminatedUnionFromInheritance(model, discriminator)).variants.keys(),
778
+ ];
779
+ const declared = discriminatorPropertyOf(program, model, discriminator.propertyName);
780
+ /**
781
+ * Undeclared, the discriminator is the `string` openapi3 adds to the base - open. Declared, it is
782
+ * closed only where its type is a finite set of strings.
783
+ */
784
+ const values = declared === undefined ? undefined : closedValuesOf(declared.type);
785
+ const beside = subtypes.filter((subtype) => isValidatedWithBaseArm(program, subtype));
786
+ return {
787
+ propertyName: discriminator.propertyName,
788
+ dispatched: subtypes.filter((subtype) => !beside.includes(subtype)),
789
+ beside,
790
+ claimed,
791
+ open: values === undefined || values.some((value) => !claimed.includes(value)),
792
+ };
793
+ }
794
+ /** Whether a model is a base validated as a `z.union` rather than a `z.discriminatedUnion`. */
795
+ function isValidatedWithBaseArm(program, model) {
796
+ const base = discriminatedBaseOf(program, model);
797
+ return base !== undefined && (base.open || base.beside.length > 0);
798
+ }
799
+ /** The base's own discriminator property, matched by wire name as openapi3 matches it. */
800
+ function discriminatorPropertyOf(program, model, propertyName) {
801
+ return inheritedAndOwnProperties(model).find((property) => rawPropertyKey(program, property) === propertyName);
802
+ }
803
+ /** The strings a type admits, where they are finite; `undefined` where it admits any string. */
804
+ function closedValuesOf(type) {
805
+ if (type.kind === "String")
806
+ return [type.value];
807
+ if (type.kind === "EnumMember") {
808
+ const value = type.value ?? type.name;
809
+ return typeof value === "string" ? [value] : undefined;
810
+ }
811
+ const members = type.kind === "Enum"
812
+ ? [...type.members.values()]
813
+ : type.kind === "Union"
814
+ ? [...type.variants.values()].map((variant) => variant.type)
815
+ : undefined;
816
+ if (members === undefined)
817
+ return undefined;
818
+ const values = [];
819
+ for (const member of members) {
820
+ const closed = closedValuesOf(member);
821
+ if (closed === undefined)
822
+ return undefined;
823
+ values.push(...closed);
824
+ }
825
+ return values;
826
+ }
827
+ /**
828
+ * The base arm's discriminator, refused for every value a subtype claims.
829
+ *
830
+ * **Without this a known kind falls through to the base.** `z.union` tries the dispatch and then the
831
+ * base arm, and a base with subtypes is never sealed, so `{kind: "sparrow", wingspan: 1, x: 1}` -
832
+ * refused by the sealed `Sparrow` - would be accepted as a `Bird` with `x` stripped. Guarded, a value
833
+ * naming a subtype is validated as that subtype and nothing else.
834
+ *
835
+ * `abort` keeps this refusal out of the error a caller reads: Zod reports the one arm that did not
836
+ * abort, which for a known kind is the subtype that was actually broken.
837
+ */
838
+ function guardedDiscriminator(schema, base, model) {
839
+ if (base.claimed.length === 0)
840
+ return schema;
841
+ const test = base.claimed.map((value) => `value !== ${literal(value)}`).join(" && ");
842
+ const error = literal(`Validated as the subtype this '${base.propertyName}' names, not as '${model.name}'.`);
843
+ return `${schema}.refine((value) => ${test}, { abort: true, error: ${error} })`;
844
+ }
681
845
  function modelToZod(program, model) {
682
846
  /**
683
- * **A `@discriminator` base is a CHOICE between its subtypes, not a shape of its own.**
847
+ * **A `@discriminator` base is a CHOICE between its subtypes, and its own shape for any value
848
+ * naming none of them.** See {@link DiscriminatedBase}.
684
849
  *
685
850
  * `@discriminator("kind") model Bird { kind: string; wingspan: int32 }` with four models extending
686
851
  * it reaches OpenAPI as a component carrying `discriminator: {propertyName, mapping}`, and the
687
852
  * mapping is an instruction: validate the body against whichever subtype the discriminator names.
688
- * Emitting the base's own properties instead produced `z.object({kind: z.string(), wingspan})`,
853
+ * Emitting the base's own properties alone produced `z.object({kind: z.string(), wingspan})`,
689
854
  * which accepts `{kind: "eagle"}` while ignoring everything `Eagle` actually declares - a
690
855
  * polymorphic endpoint validating none of its polymorphism.
691
856
  *
692
- * `z.discriminatedUnion` is the faithful encoding and composes both ways this corpus needs.
693
- * Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
857
+ * `z.discriminatedUnion` is the faithful encoding of the dispatch and composes both ways this
858
+ * corpus needs. Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
694
859
  * `Fish -> Shark -> SawShark` works; and an option carrying a getter that refers back to the union
695
860
  * being defined does **not** throw, because the discriminator is read without enumerating the rest
696
861
  * of the shape. `Eagle.friends?: Bird[]` is exactly that, and it decides whether polymorphism and
697
862
  * recursion can coexist at all.
863
+ *
864
+ * **`z.discriminatedUnion` has no arm for "any other value"**, and `unionFallback` cannot add one:
865
+ * it retries the same options, each of which must carry literal discriminator values. So an open
866
+ * base is `z.union([dispatch, ...nested bases, base arm])`, every arm exclusive on the
867
+ * discriminator - each subtype by its literal, the base arm by {@link guardedDiscriminator}.
698
868
  */
699
- const subtypes = discriminatedSubtypes(program, model);
700
- if (subtypes.length > 0) {
701
- const discriminator = getDiscriminator(program, model);
702
- const options = subtypes.map((derived) => typeToZod(program, derived));
703
- return `z.discriminatedUnion(${JSON.stringify(discriminator?.propertyName)}, [${options.join(", ")}])`;
869
+ const base = discriminatedBaseOf(program, model);
870
+ if (base !== undefined) {
871
+ const arms = [];
872
+ if (base.dispatched.length > 0) {
873
+ const options = base.dispatched.map((derived) => typeToZod(program, derived));
874
+ arms.push(`z.discriminatedUnion(${literal(base.propertyName)}, [${options.join(", ")}])`);
875
+ }
876
+ arms.push(...base.beside.map((derived) => typeToZod(program, derived)));
877
+ if (base.open)
878
+ arms.push(modelObjectToZod(program, model, base));
879
+ return arms.length === 1 ? (arms[0] ?? UNREPRESENTABLE) : `z.union([${arms.join(", ")}])`;
704
880
  }
881
+ return modelObjectToZod(program, model);
882
+ }
883
+ /**
884
+ * A model as the object of its own properties, inherited ones included.
885
+ *
886
+ * **Given a discriminated base, this is its base arm**: the same object, with its discriminator
887
+ * refused for every value a subtype claims, and - where the base does not declare the discriminator -
888
+ * the required `string` openapi3 adds to the base's properties in its place.
889
+ */
890
+ function modelObjectToZod(program, model, base) {
705
891
  const indexer = effectiveIndexer(model);
706
892
  const indexerKey = indexer?.key.name;
707
893
  const declared = inheritedAndOwnProperties(model);
@@ -816,13 +1002,23 @@ function modelToZod(program, model) {
816
1002
  * parameters for the reason `renderSchemas` names both: one leaves the input side `unknown`.
817
1003
  */
818
1004
  const annotation = deferred ? (annotateDeferred(property) ?? "") : "";
1005
+ const guarded = base !== undefined && rawPropertyKey(program, property) === base.propertyName
1006
+ ? guardedDiscriminator(value, base, model)
1007
+ : value;
819
1008
  return {
820
1009
  deferred,
821
1010
  text: deferred
822
- ? `\tget ${key}()${annotation} {\n\t\treturn ${value};\n\t},`
823
- : `\t${key}: ${value},`,
1011
+ ? `\tget ${key}()${annotation} {\n\t\treturn ${guarded};\n\t},`
1012
+ : `\t${key}: ${guarded},`,
824
1013
  };
825
1014
  });
1015
+ if (base !== undefined &&
1016
+ discriminatorPropertyOf(program, model, base.propertyName) === undefined) {
1017
+ members.push({
1018
+ deferred: false,
1019
+ text: `\t${objectKey(base.propertyName)}: ${guardedDiscriminator("z.string()", base, model)},`,
1020
+ });
1021
+ }
826
1022
  const entries = members.map((member) => member.text);
827
1023
  /**
828
1024
  * A model-level `@refine` goes through the same path as every other constraint, so the predicate's
@@ -901,8 +1097,10 @@ const NUMERIC_EXPRESSIONS = ["z.number()", "z.int(", "z.int32(", "z.uint32("];
901
1097
  export function wireKindOf(program, type) {
902
1098
  if (type.kind === "Scalar") {
903
1099
  const encoded = encodedTypeOf(program, type);
904
- if (encoded !== undefined && encoded !== type)
905
- return wireKindOf(program, encoded);
1100
+ if (encoded !== undefined && encoded !== type) {
1101
+ // Reported by the schema walk; a chain with no end has no kind to decode into.
1102
+ return encodeCycleOf(program, type) === undefined ? wireKindOf(program, encoded) : undefined;
1103
+ }
906
1104
  for (let current = type; current !== undefined; current = current.baseScalar) {
907
1105
  const mapped = SCALARS[current.name];
908
1106
  if (mapped === undefined)
@@ -945,7 +1143,16 @@ export function wireKindOf(program, type) {
945
1143
  * Exported so there is one answer to this question. There were two sites and only one of them knew.
946
1144
  */
947
1145
  export function objectKey(name) {
948
- return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
1146
+ /**
1147
+ * **`__proto__` is the one name a key cannot spell plainly.** In an object literal, `__proto__:`
1148
+ * - quoted or not - sets the object's prototype rather than declaring a key, so the emitted shape
1149
+ * had no such property: it vanished from the published JSON Schema, and under
1150
+ * `seal-object-schemas` every body carrying it was refused as an unrecognised key. A computed
1151
+ * key declares it, in a value and in a type alike. See `unenforceable-property-name`.
1152
+ */
1153
+ if (name === "__proto__")
1154
+ return `[${literal(name)}]`;
1155
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : literal(name);
949
1156
  }
950
1157
  /**
951
1158
  * A doc string as a JSDoc comment, indented, or `""` when there is none.
@@ -1063,9 +1270,56 @@ const PARAMETER_STYLE_ENCODINGS = new Set([
1063
1270
  "ArrayEncoding.commaDelimited",
1064
1271
  "ArrayEncoding.newlineDelimited",
1065
1272
  ]);
1273
+ /**
1274
+ * **A date-time header with no `@encode` is an RFC 7231 HTTP-date, as if it said
1275
+ * `@encode(DateTimeKnownEncoding.rfc7231)`.**
1276
+ *
1277
+ * That is TypeSpec's wire form for one, and `@typespec/openapi3` publishes it as
1278
+ * `format: http-date` from `applyEncoding`'s `isDateTimeHeader` branch, which this emitter did not
1279
+ * follow. The header fell through to the scalar's default, `z.iso.datetime({ offset: true })`, so a
1280
+ * request sending it as the document describes was refused with 400, and a handler setting it as an
1281
+ * HTTP-date failed its own response contract with 500 (issue #4). The explicit encoding already
1282
+ * emitted `z.string()`, and this is that.
1283
+ *
1284
+ * **The predicate is openapi3's: the header's own schema carries `format: date-time`.** That is
1285
+ * the standard `utcDateTime` or `offsetDateTime` itself. A declared scalar extending one is
1286
+ * published as a `$ref`, a list carries the format on its items, and `T | null` is an `anyOf`
1287
+ * under 3.1 and 3.2 - the versions this package grades against - so none of those is an HTTP-date
1288
+ * in the document, and none is here. (3.0 publishes the nullable one as `nullable: true` beside an
1289
+ * `http-date` format; the graded documents do not.)
1290
+ */
1291
+ function isDateTimeHeader(program, property) {
1292
+ const type = property.type;
1293
+ return isHeader(program, property) && type.kind === "Scalar" && isStdDateTime(program, type);
1294
+ }
1295
+ function isStdDateTime(program, scalar) {
1296
+ const { checker } = program;
1297
+ return (scalar === checker.getStdType("utcDateTime") || scalar === checker.getStdType("offsetDateTime"));
1298
+ }
1299
+ /** Whether a property's value, or a scalar, is a date-time: one of the two, or built on one. */
1300
+ function isDateTime(program, target) {
1301
+ const type = target.kind === "ModelProperty" ? (nullableInnerOf(target.type) ?? target.type) : target;
1302
+ for (let current = type.kind === "Scalar" ? type : undefined; current !== undefined; current = current.baseScalar) {
1303
+ if (isStdDateTime(program, current))
1304
+ return true;
1305
+ }
1306
+ return false;
1307
+ }
1066
1308
  export function encodedTypeOf(program, target) {
1067
1309
  const encoded = getEncode(program, target);
1068
- if (encoded === undefined)
1310
+ if (encoded === undefined) {
1311
+ return target.kind === "ModelProperty" && isDateTimeHeader(program, target)
1312
+ ? program.checker.getStdType("string")
1313
+ : undefined;
1314
+ }
1315
+ /**
1316
+ * **`rfc3339` on a date-time is the form it already has, so it changes nothing.** openapi3's
1317
+ * `mergeFormatAndEncoding` keeps `format: date-time` for it, and the type it reports, `string`,
1318
+ * emitted `z.string()`: the explicit spelling of the default dropped the date-time check the
1319
+ * document still states. Only the encodings that change the wire form - `rfc7231`,
1320
+ * `unixTimestamp` - change the type.
1321
+ */
1322
+ if (encoded.encoding === "rfc3339" && isDateTime(program, target))
1069
1323
  return undefined;
1070
1324
  if (target.kind === "ModelProperty" &&
1071
1325
  isQueryParam(program, target) &&
@@ -1091,9 +1345,26 @@ export function propertyToZod(program, property) {
1091
1345
  // wrapper is kept: `@encode` changes what the value IS, not whether it may be absent.
1092
1346
  const encoded = encodedTypeOf(program, property);
1093
1347
  const declared = encoded ?? nullable ?? property.type;
1094
- const base = nullable === undefined || encoded !== undefined
1095
- ? applyConstraints(program, typeToZod(program, declared), property)
1096
- : `${applyConstraints(program, typeToZod(program, declared), property)}.nullable()`;
1348
+ const bare = nullable === undefined || encoded !== undefined;
1349
+ /**
1350
+ * **One description per position, and a property's own doc wins over its scalar's.**
1351
+ *
1352
+ * openapi3 publishes `subject: Subject` as `{ $ref: Subject, description: <the property's doc> }`,
1353
+ * and a reader takes the sibling over what the reference carries. A scalar here is inlined rather
1354
+ * than referenced, so where the property's value IS the scalar, its schema is taken without its
1355
+ * description and the one the position publishes is decided once: the property's doc, else the
1356
+ * scalar's. Otherwise a documented property of a documented scalar was written
1357
+ * `.describe(a).describe(b)`, which says the same thing in a way no one would write.
1358
+ *
1359
+ * Under `.nullable()` the scalar keeps its own, inside the wrapper: openapi3 publishes
1360
+ * `anyOf: [{ $ref: Subject }, { type: "null" }]` there, so the scalar's description belongs to the
1361
+ * branch and only the property's belongs to the property.
1362
+ */
1363
+ const inlinedScalar = bare && declared.kind === "Scalar";
1364
+ const value = inlinedScalar ? scalarSchemaOf(program, declared) : typeToZod(program, declared);
1365
+ const constrained = applyConstraints(program, value, property);
1366
+ const base = bare ? constrained : `${constrained}.nullable()`;
1367
+ const description = getDoc(program, property) ?? (inlinedScalar ? scalarDocOf(program, declared) : undefined);
1097
1368
  /**
1098
1369
  * **Optionality is a function of the position too, not only of the `?` in the spec.**
1099
1370
  *
@@ -1104,6 +1375,7 @@ export function propertyToZod(program, property) {
1104
1375
  * predicate `@typespec/openapi3` uses to decide the `required` list.
1105
1376
  */
1106
1377
  const optional = property.optional || isOptionalAt(program, property);
1378
+ reportInheritedName(program, property, optional);
1107
1379
  /**
1108
1380
  * **A default only applies where the document says the property may be absent.**
1109
1381
  *
@@ -1125,7 +1397,7 @@ export function propertyToZod(program, property) {
1125
1397
  target: property,
1126
1398
  format: { name: property.name },
1127
1399
  });
1128
- return base;
1400
+ return described(base, description);
1129
1401
  }
1130
1402
  /**
1131
1403
  * A TypeSpec default (`basis?: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
@@ -1136,7 +1408,7 @@ export function propertyToZod(program, property) {
1136
1408
  */
1137
1409
  const defaulted = defaultOf(program, property);
1138
1410
  if (defaulted !== undefined)
1139
- return `${base}.default(${defaulted})`;
1411
+ return described(`${base}.default(${defaulted})`, description);
1140
1412
  /**
1141
1413
  * **`.exactOptional()`, because the document says the KEY may be absent and nothing more.**
1142
1414
  *
@@ -1155,7 +1427,66 @@ export function propertyToZod(program, property) {
1155
1427
  * differential's serialisation axis is untouched, and `def.type` is still `"optional"`, so the
1156
1428
  * shape describers read it unchanged.
1157
1429
  */
1158
- return optional ? `${base}.exactOptional()` : base;
1430
+ return described(optional ? `${base}.exactOptional()` : base, description);
1431
+ }
1432
+ /**
1433
+ * The names every JavaScript object inherits, which Zod reads in place of an absent property.
1434
+ * `__proto__` is among them and is the different case: Zod skips it outright.
1435
+ */
1436
+ const INHERITED_NAMES = new Set(Object.getOwnPropertyNames(Object.prototype));
1437
+ /** Properties already reported, by declaration: one property renders once per visibility. */
1438
+ const reportedNames = new WeakMap();
1439
+ /**
1440
+ * **A wire name the validator cannot treat as an ordinary key is reported where it bites.** See
1441
+ * `unenforceable-property-name` and `optional-prototype-name` in `lib.ts` for why each is a
1442
+ * warning and what each costs.
1443
+ *
1444
+ * Reported here rather than from a walk over the program because `optional` is a fact about the
1445
+ * POSITION: a PATCH makes a required property optional, and only there does an inherited name
1446
+ * refuse its omission. Once per declaration, by its syntax node, because a visibility projection
1447
+ * hands this a different `ModelProperty` for the same one.
1448
+ */
1449
+ function reportInheritedName(program, property, optional) {
1450
+ const name = wireNameOf(program, property);
1451
+ if (!INHERITED_NAMES.has(name))
1452
+ return;
1453
+ const unenforceable = name === "__proto__";
1454
+ if (!unenforceable && !optional)
1455
+ return;
1456
+ const declaration = property.node ?? property;
1457
+ let reported = reportedNames.get(program);
1458
+ if (reported === undefined)
1459
+ reportedNames.set(program, (reported = new WeakSet()));
1460
+ if (reported.has(declaration))
1461
+ return;
1462
+ reported.add(declaration);
1463
+ if (unenforceable) {
1464
+ reportDiagnostic(program, {
1465
+ code: "unenforceable-property-name",
1466
+ target: property,
1467
+ format: { name },
1468
+ });
1469
+ }
1470
+ else {
1471
+ reportDiagnostic(program, {
1472
+ code: "optional-prototype-name",
1473
+ target: property,
1474
+ format: { name },
1475
+ });
1476
+ }
1477
+ }
1478
+ /** The key a property is validated under: its parameter name, or its name in a JSON body. */
1479
+ function wireNameOf(program, property) {
1480
+ if (isHeader(program, property))
1481
+ return getHeaderFieldName(program, property);
1482
+ if (isQueryParam(program, property))
1483
+ return getQueryParamName(program, property) ?? property.name;
1484
+ if (isPathParam(program, property))
1485
+ return getPathParamName(program, property) ?? property.name;
1486
+ if (isCookieParam(program, property)) {
1487
+ return getCookieParamOptions(program, property)?.name ?? property.name;
1488
+ }
1489
+ return rawPropertyKey(program, property);
1159
1490
  }
1160
1491
  /**
1161
1492
  * A default value as a JS literal, or `undefined` where this emitter cannot render one.
@@ -1180,7 +1511,7 @@ export function propertyToZod(program, property) {
1180
1511
  */
1181
1512
  function renderValue(value) {
1182
1513
  if (value.valueKind === "StringValue")
1183
- return JSON.stringify(value.value);
1514
+ return literal(value.value);
1184
1515
  if (value.valueKind === "NumericValue")
1185
1516
  return String(value.value.asNumber());
1186
1517
  if (value.valueKind === "BooleanValue")
@@ -1189,7 +1520,7 @@ function renderValue(value) {
1189
1520
  return "null";
1190
1521
  if (value.valueKind === "EnumValue") {
1191
1522
  const member = value.value;
1192
- return JSON.stringify(member.value ?? member.name);
1523
+ return literal(member.value ?? member.name);
1193
1524
  }
1194
1525
  if (value.valueKind === "ArrayValue") {
1195
1526
  const rendered = value.values.map(renderValue);
@@ -1203,7 +1534,7 @@ function renderValue(value) {
1203
1534
  const rendered = renderValue(descriptor.value);
1204
1535
  if (rendered === undefined)
1205
1536
  return undefined;
1206
- entries.push(`${JSON.stringify(name)}: ${rendered}`);
1537
+ entries.push(`${literal(name)}: ${rendered}`);
1207
1538
  }
1208
1539
  return `{${entries.join(", ")}}`;
1209
1540
  }
@@ -1330,13 +1661,13 @@ export function typeToZodBody(program, type) {
1330
1661
  case "Union":
1331
1662
  return unionToZod(program, type);
1332
1663
  case "String":
1333
- return `z.literal(${JSON.stringify(type.value)})`;
1664
+ return `z.literal(${literal(type.value)})`;
1334
1665
  case "Number":
1335
1666
  return `z.literal(${type.value})`;
1336
1667
  case "Boolean":
1337
1668
  return `z.literal(${String(type.value)})`;
1338
1669
  case "EnumMember":
1339
- return `z.literal(${JSON.stringify(type.value ?? type.name)})`;
1670
+ return `z.literal(${literal(type.value ?? type.name)})`;
1340
1671
  /**
1341
1672
  * **A named union VARIANT is a type you can refer to, and it is not the union.**
1342
1673
  *