typespec-http-zod 0.21.0 → 0.23.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/api.d.ts CHANGED
@@ -263,6 +263,8 @@ export interface EmittedRoute {
263
263
  readonly headers: readonly {
264
264
  readonly name: string;
265
265
  readonly property: string;
266
+ /** The TypeScript type of the value, so a server emitter can put it in a signature. */
267
+ readonly type: string;
266
268
  }[];
267
269
  }[];
268
270
  /**
@@ -302,6 +304,18 @@ export interface EmittedRoute {
302
304
  value: boolean | string;
303
305
  status: number;
304
306
  } | undefined;
307
+ /**
308
+ * The property a handler sets to choose between several declared success statuses, and the
309
+ * statuses it may carry - read from a `@statusCode` typed as a union of literals.
310
+ *
311
+ * **Published because the handler has to be able to SAY it.** The property is HTTP metadata, so it
312
+ * is stripped from the body schema (correctly - it is not body), which means a server emitter has
313
+ * to add it back to the type a handler returns or the arms name something unsatisfiable.
314
+ */
315
+ readonly statusSelector: {
316
+ property: string;
317
+ statuses: readonly number[];
318
+ } | undefined;
305
319
  /** The schema for the SECOND success status, when the operation declares two distinct arms. */
306
320
  readonly alternateResponseSchema: string | undefined;
307
321
  }
package/dist/src/api.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
2
- import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
2
+ import { getAuthenticationForOperation, isStatusCode, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
5
5
  import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
+ import { typeToTs } from "./types.js";
6
7
  import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
7
8
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
8
9
  import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
@@ -306,7 +307,7 @@ function isSuccessKey(status) {
306
307
  * body and gives each one under its WIRE name with the declaring property behind it. Deriving either
307
308
  * name any other way would be a second spelling of a fact the compiler already resolved.
308
309
  */
309
- function responseHeadersOf(operation) {
310
+ function responseHeadersOf(program, operation) {
310
311
  const byStatus = new Map();
311
312
  for (const response of operation.responses) {
312
313
  const status = response.statusCodes;
@@ -316,7 +317,14 @@ function responseHeadersOf(operation) {
316
317
  for (const content of response.responses) {
317
318
  for (const [name, property] of Object.entries(content.headers ?? {})) {
318
319
  if (!headers.some((existing) => existing.name === name)) {
319
- headers.push({ name, property: property.name });
320
+ /**
321
+ * **The TYPE too, because a handler has to be able to SET this.** A `@header`
322
+ * property is stripped from the body schema - correctly, it is not body - so a server
323
+ * emitter has to put it back into the type a handler returns, and it cannot do that
324
+ * from a name alone. Measured before this existed: an arm naming `contentType` and
325
+ * `metadata` against a handler declared `Awaitable<Result<void>>`.
326
+ */
327
+ headers.push({ name, property: property.name, type: typeToTs(program, property.type) });
320
328
  }
321
329
  }
322
330
  }
@@ -579,6 +587,36 @@ function statusDiscriminatorOf(operation, statuses) {
579
587
  }
580
588
  return undefined;
581
589
  }
590
+ /**
591
+ * How a handler says WHICH success status it means, when the spec declares more than one.
592
+ *
593
+ * **The `@statusCode` property is the selector, and it is the one the spec already wrote.**
594
+ * `model Created { @statusCode statusCode: 200 | 201; @body body: Item }` declares both statuses and
595
+ * names the property that chooses between them, so nothing has to be inferred from the body's shape.
596
+ *
597
+ * That inference is what {@link statusDiscriminatorOf} does, and it needs a required literal property
598
+ * on one of the bodies. Measured across the whole conformance corpus: it fires ZERO times, so before
599
+ * this the second arm had never been emitted by any spec at all - `armFor` could not select a status
600
+ * that was never written. Both remain: a discriminator still serves two DIFFERENT response models,
601
+ * this serves one model declaring a union of statuses.
602
+ *
603
+ * All statuses of such a model share one body by construction - it is one model - so every arm
604
+ * carries the same schema and only the status and the selector value differ.
605
+ */
606
+ function statusSelectorOf(program, operation, statuses) {
607
+ if (statuses.length < 2)
608
+ return undefined;
609
+ for (const response of operation.responses) {
610
+ const type = response.type;
611
+ if (type === undefined || type.kind !== "Model")
612
+ continue;
613
+ for (const [name, property] of type.properties) {
614
+ if (isStatusCode(program, property))
615
+ return { property: name, statuses };
616
+ }
617
+ }
618
+ return undefined;
619
+ }
582
620
  /** The emitted schema for a specific success status, used for the second arm of a dual-status op. */
583
621
  function alternateSchemaFor(operation, status, registry) {
584
622
  const body = successBodyOf(operation, status);
@@ -1039,6 +1077,7 @@ export function collectRoutes(program, registry, service) {
1039
1077
  const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
1040
1078
  const statusCodes = successStatusesOf(operation);
1041
1079
  const discriminator = statusDiscriminatorOf(operation, statusCodes);
1080
+ const statusSelector = statusSelectorOf(program, operation, statusCodes);
1042
1081
  /**
1043
1082
  * A `bytes` body means the BYTES are the contract.
1044
1083
  *
@@ -1145,7 +1184,7 @@ export function collectRoutes(program, registry, service) {
1145
1184
  // them under the ambient request visibility declared six components under the wrong
1146
1185
  // suffix - measured: type/model/visibility gained six divergences.
1147
1186
  errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
1148
- responseHeaders: responseHeadersOf(operation),
1187
+ responseHeaders: responseHeadersOf(program, operation),
1149
1188
  responseMediaTypes: responseMediaTypesOf(operation),
1150
1189
  scopes: [
1151
1190
  ...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
@@ -1154,6 +1193,7 @@ export function collectRoutes(program, registry, service) {
1154
1193
  ],
1155
1194
  noAuth: noAuthFor(program, operation),
1156
1195
  statusBy: discriminator,
1196
+ statusSelector,
1157
1197
  /**
1158
1198
  * **Each arm is validated against its OWN shape.**
1159
1199
  *
@@ -1242,7 +1282,32 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1242
1282
  const declared = d.structural.startsWith("{")
1243
1283
  ? `export interface ${d.typeName} ${d.structural}`
1244
1284
  : `export type ${d.typeName} = ${d.structural};`;
1245
- const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}>` : "";
1285
+ /**
1286
+ * **BOTH of Zod's type parameters, and naming only the first left the second as `unknown`.**
1287
+ *
1288
+ * `z.ZodType<T>` is `ZodType<Output = T, Input = unknown>`, so a schema annotated with one
1289
+ * parameter reports `z.input<typeof x>` as `unknown` - and every schema that REFERENCES it
1290
+ * inherits that. Measured: `Identical<Declared<z.input<typeof nodeSchema>>, Contracts.Node>`
1291
+ * failed on `branch?: unknown | undefined` against `branch?: Branch | undefined`, in a fixture
1292
+ * whose two artefacts agree about every property. The wire assertion pairs `z.input` because
1293
+ * that is what a caller supplies, so an unannotated input parameter takes the whole cyclic
1294
+ * corner of the emitter out of that comparison.
1295
+ *
1296
+ * **One structural type serves both positions, and for a defaulted property on a cycle it is
1297
+ * the INPUT shape that both get.** A default is the only construct where a schema's input and
1298
+ * output types differ, and a cyclic declaration has exactly one type written out to name - the
1299
+ * loop is what stops `z.infer` being usable at all. Claiming the input shape in the output
1300
+ * position is the PERMISSIVE direction: every value that arrives still satisfies it, a reader
1301
+ * simply learns less than it could about a defaulted property. Claiming the output shape in
1302
+ * the input position is the restrictive one, and it is the defect this release exists to
1303
+ * remove - a caller told to supply what the document says they may omit.
1304
+ *
1305
+ * So the limitation is confined to this: a model on a cycle carrying a defaulted optional
1306
+ * property exports a type from `schemas.gen.ts` that is weaker than what arrives, by exactly
1307
+ * that property. `typespec-hono` derives its handler input from `z.infer` of the schema rather
1308
+ * than from this type, so no handler signature is affected.
1309
+ */
1310
+ const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}, ${d.typeName}>` : "";
1246
1311
  return `\n${declared}\nexport const ${d.identifier}${annotation} = ${d.source};\n`;
1247
1312
  });
1248
1313
  /**
@@ -1279,7 +1344,9 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1279
1344
  * So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
1280
1345
  * one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
1281
1346
  * may SEND. A reader comparing them will find them unequal and should - the emitted assertion
1282
- * pairs the contract against raw `z.infer`, not against this.
1347
+ * pairs the contract against raw `z.input`, not against this. They differ on a second axis for
1348
+ * the same reason: a defaulted property is present here, because a default has fired by the time
1349
+ * a value arrives, and optional in the contract type, because a caller may omit it.
1283
1350
  *
1284
1351
  * Derived from the same schema rather than hand-written beside it, so there is still one source of
1285
1352
  * truth: a mapped type cannot drift from the thing it maps.
@@ -1461,7 +1528,22 @@ function responseArmsOf(route, response, alternate) {
1461
1528
  .join(", ");
1462
1529
  return `, headers: [${rendered}]`;
1463
1530
  };
1464
- if (route.statusBy === undefined) {
1531
+ if (route.statusSelector !== undefined) {
1532
+ /**
1533
+ * **One arm per declared status, keyed on the property the spec already named.**
1534
+ *
1535
+ * The selector arms come first and the default last, the same order the discriminator below
1536
+ * uses: anything scanning for a match wants the specific ones before the fallback. Every arm
1537
+ * carries the same schema because a `@statusCode` union is ONE model - the statuses differ,
1538
+ * the body does not.
1539
+ */
1540
+ const { property, statuses } = route.statusSelector;
1541
+ for (const status of statuses.filter((candidate) => candidate !== route.statusCode)) {
1542
+ arms.push(`{ status: ${status}, schema: ${primary}${mediaTypesFor(status)}${headersFor(status)}, when: { property: ${JSON.stringify(property)}, value: ${status} } }`);
1543
+ }
1544
+ arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1545
+ }
1546
+ else if (route.statusBy === undefined) {
1465
1547
  arms.push(`{ status: ${route.statusCode}, schema: ${primary}${mediaTypesFor(route.statusCode)}${headersFor(route.statusCode)} }`);
1466
1548
  }
1467
1549
  else {
@@ -1740,8 +1822,26 @@ ${responseLookup}
1740
1822
  * both look right in isolation. That happened during this emitter's first run: a vocabulary in a
1741
1823
  * query-parameter position was inlined by one walk and referenced by the other.
1742
1824
  *
1743
- * `Equals` here, not assignability - `z.infer` is the output type, and the wire type is emitted with
1744
- * defaulted properties required for exactly that reason, so the two really are equal.
1825
+ * **`z.input`, not `z.infer`, and the difference is the whole caller-side question.** `z.infer` is
1826
+ * the OUTPUT type - what a handler receives, after `.default(...)` has fired. These contract types
1827
+ * are the floor a PRODUCER supplies, and for a request the producer is the caller, who has not run
1828
+ * the validator. `z.input` is what the validator ACCEPTS, which is what a request contract states
1829
+ * and what the document's `required` list says. Measured on zod 4.4.3: `.default()` is the only
1830
+ * construct this emitter emits where the two differ - `.optional()`, `.nullable()` and every
1831
+ * constraint infer identically on both sides.
1832
+ *
1833
+ * Once `default-on-required-property` has removed the incoherent spelling, `z.input` and the
1834
+ * document's `required` list agree property for property, so this assertion cross-checks that rule
1835
+ * rather than merely restating one side of it.
1836
+ *
1837
+ * **One measured exception, which does not reach here.** `.refine()` with a type predicate narrows
1838
+ * the OUTPUT only - zod types it `this & ZodType<R, core.input<this>>` - so `z.input` of a
1839
+ * `HttpPart<File>` part is `unknown` where `z.infer` is the narrowed file shape. A multipart body's
1840
+ * contract type is an inline object literal rather than a named declaration, so it is never in the
1841
+ * paired set below; if that ever changes, this is the pairing that breaks first.
1842
+ *
1843
+ * `Equals` here, not assignability: the two describe one shape, and a property present on one side
1844
+ * and absent from the other is exactly the drift this exists to catch.
1745
1845
  */
1746
1846
  function renderWireAssertions(schemas, types, entries, contractsPackage) {
1747
1847
  // The assertions pair the emitted Zod against the request TYPES, which live in the contracts
@@ -1763,7 +1863,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
1763
1863
  }
1764
1864
  if (paired.length === 0)
1765
1865
  return undefined;
1766
- const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.infer<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
1866
+ const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
1767
1867
  return `${generatedBanner()}
1768
1868
  import type { z } from "zod";
1769
1869
  import type * as Contracts from "${contractsPackage}";
package/dist/src/lib.d.ts CHANGED
@@ -119,6 +119,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
119
119
  "unsupported-default": {
120
120
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
121
121
  };
122
+ "default-on-required-property": {
123
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
124
+ };
122
125
  "undeclared-discriminator": {
123
126
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
124
127
  };
@@ -141,13 +144,16 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
141
144
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
142
145
  };
143
146
  }, EmitterOptions, never>;
144
- export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
147
+ export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
145
148
  "unsupported-type": {
146
149
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
147
150
  };
148
151
  "unsupported-default": {
149
152
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
150
153
  };
154
+ "default-on-required-property": {
155
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
156
+ };
151
157
  "undeclared-discriminator": {
152
158
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
153
159
  };
@@ -176,6 +182,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
176
182
  "unsupported-default": {
177
183
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
178
184
  };
185
+ "default-on-required-property": {
186
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
187
+ };
179
188
  "undeclared-discriminator": {
180
189
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
181
190
  };
package/dist/src/lib.js CHANGED
@@ -76,6 +76,42 @@ const diagnostics = {
76
76
  default: paramMessage `Cannot emit a default value of ${"why"}. Scalars, arrays and objects are emitted as literals; a default built by a scalar constructor has no literal form. Replace it with a literal value, or drop the default and let the property be optional. The property keeps its declared shape and loses only the fallback.`,
77
77
  },
78
78
  },
79
+ /**
80
+ * **A default on a REQUIRED property, where the default can never apply.**
81
+ *
82
+ * **`default` is an annotation and `required` is the assertion**, and this emitter used to
83
+ * implement the opposite. JSON Schema 2020-12 section 9.2 makes `default` an annotation keyword
84
+ * with no effect on validation, and `@typespec/openapi3` implements exactly that:
85
+ * `#requiredModelProperties` builds the `required` list from `metadataInfo.isOptional` alone and
86
+ * never consults a default, then attaches the default beside it as a plain `schema.default`. The
87
+ * parameter side is the same rule spelled differently, `required: !param.optional`.
88
+ *
89
+ * So `basis: string = "s1159"` publishes as REQUIRED with an annotation nothing can reach. This
90
+ * emitter emitted `.default("s1159")`, which makes the field optional on the way IN - a validator
91
+ * accepting a request the document generated beside it forbids. OAI/OpenAPI-Specification#1543 is
92
+ * closed COMPLETED on this exact question, with the JSON Schema editor's answer that writing a
93
+ * default in before validating "is **NOT** part of validation", and the OpenAPI maintainer's that
94
+ * a default beside `required` "risks making the `required` notion invalid and encourages clients
95
+ * to unnecessarily send `default` values over the wire". Swagger's own documentation lists the
96
+ * combination as one of the two common mistakes with the keyword.
97
+ *
98
+ * **A WARNING, and this package's first, because the spec is representable.** `unsupported-default`
99
+ * used to be an error on composite literals and was retired for precisely this reason: refusing a
100
+ * construct `@typespec/openapi3` emits makes the same spec representable by one emitter and not the
101
+ * other, which is the one thing a differential between the two cannot tolerate. openapi3 emits this
102
+ * one, so this emitter serves it - saying what the document says, and saying out loud that the
103
+ * annotation is dead. The fix is one character and the message names it.
104
+ *
105
+ * The predicate is the DOCUMENT's, `metadataInfo.isOptional`, so this fires exactly where openapi3
106
+ * writes the name into `required` - including the PATCH position, where an update body's properties
107
+ * are optional and a default on one of them is live rather than dead.
108
+ */
109
+ "default-on-required-property": {
110
+ severity: "warning",
111
+ messages: {
112
+ default: paramMessage `'${"name"}' is required, so its default can never apply: a required property is never absent. The OpenAPI document publishes it in 'required' and carries the default only as an annotation, and this validator does the same - a request omitting '${"name"}' is refused. Declare it optional, '${"name"}?: ... = ...', if callers may omit it.`,
113
+ },
114
+ },
79
115
  /**
80
116
  * **A discriminated union whose discriminator the document never publishes.**
81
117
  *
@@ -78,9 +78,17 @@ export interface ResponseArm {
78
78
  */
79
79
  readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
80
80
  readonly schema: ZodType | undefined;
81
+ /**
82
+ * How a handler said WHICH arm it meant, where an operation declares more than one success.
83
+ *
84
+ * **`number` too, because a `@statusCode` union selects by the status itself.**
85
+ * `model Created { @statusCode statusCode: 200 | 201 }` names the property that chooses and its
86
+ * values are the statuses; the discriminator case carries a boolean or string literal off the body
87
+ * instead. Both answer the same question, so both use this one field.
88
+ */
81
89
  readonly when?: {
82
90
  readonly property: string;
83
- readonly value: boolean | string;
91
+ readonly value: boolean | number | string;
84
92
  };
85
93
  }
86
94
  /**
package/dist/src/types.js CHANGED
@@ -13,9 +13,12 @@ import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, obj
13
13
  *
14
14
  * **It must agree with `zod.ts` exactly**, because the emitted assertion compares them. Every rule
15
15
  * here has a counterpart there: `utcDateTime` is a string on both sides, `?` becomes
16
- * `?: T | undefined` because `exactOptionalPropertyTypes` is on, and a defaulted property is
17
- * **required** here because `z.infer` reports the *output* type and a default has always fired by
18
- * then.
16
+ * `?: T | undefined` because `exactOptionalPropertyTypes` is on, and a property the document leaves
17
+ * out of `required` is optional here whether its absence is spelled `?` or covered by a default.
18
+ *
19
+ * **The assertion pairs this walk against `z.input`, the type the validator ACCEPTS**, and not
20
+ * against `z.infer`, which is what it produces. These types are the floor a producer supplies, and
21
+ * for a request the producer is a caller who has not run the validator.
19
22
  */
20
23
  /** Scalars we accept, and the TypeScript they become. Mirrors `SCALARS` in `zod.ts`. */
21
24
  const SCALARS = {
@@ -247,15 +250,30 @@ export function propertyToTs(program, property, wireName) {
247
250
  const encoded = getEncode(program, property)?.type;
248
251
  const value = typeToTs(program, encoded ?? property.type);
249
252
  /**
250
- * **A defaulted property is REQUIRED here.**
253
+ * **A defaulted property used to be REQUIRED here, and that was the OUTPUT type's answer to a
254
+ * question this file does not ask.**
255
+ *
256
+ * The reasoning was: `zod.ts` emits `.default(v)`, the assertion compares against `z.infer` which
257
+ * is the output type, and by then the default has fired. Every step of that is true and the
258
+ * conclusion served the wrong reader. **These types are the floor a PRODUCER supplies**, and for
259
+ * a request the producer is the CALLER - who has not run the validator, and for whom the document
260
+ * is the only contract. The document leaves an optional defaulted property out of `required`, so
261
+ * a caller may omit it, and this file said they could not.
262
+ *
263
+ * Reported by a consumer building requests from a zero-dependency contracts package, carrying a
264
+ * named helper to relax six such properties by hand. The helper could only reach the top level; a
265
+ * default one level down stayed required and no caller could build the value at all. Emitting the
266
+ * optionality per property fixes every depth at once, because there is no depth to reach.
267
+ *
268
+ * **The two directions of the emitted set differ here on purpose**, exactly as they do on
269
+ * openness: `schemas.gen.ts` exports `Exact<z.infer<...>>`, the narrow view of what ARRIVES, in
270
+ * which a default has fired and the property is present. `wire-contract.gen.ts` pairs this walk
271
+ * against `z.input` for the same reason.
251
272
  *
252
- * `zod.ts` emits `.default(v)`, which makes the field optional on the way *in* and guaranteed on
253
- * the way *out*. The assertion this type feeds compares against `z.infer`, which is the output
254
- * type - so by the time a value reaches the wire the default has fired and the field is present.
255
- * Marking it optional would make a correct schema fail its own assertion.
273
+ * A defaulted property that reaches here is one the document publishes as optional, because
274
+ * `propertyToZod` reports `default-on-required-property` for the other spelling and emits no
275
+ * `.default(...)` for it - so the two walks agree without either one restating the rule.
256
276
  */
257
- if (property.defaultValue !== undefined)
258
- return `${key}: ${value};`;
259
277
  /**
260
278
  * **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
261
279
  *
package/dist/src/zod.js CHANGED
@@ -978,14 +978,6 @@ export function propertyToZod(program, property) {
978
978
  const base = nullable === undefined || encoded !== undefined
979
979
  ? applyConstraints(program, typeToZod(program, declared), property)
980
980
  : `${applyConstraints(program, typeToZod(program, declared), property)}.nullable()`;
981
- /**
982
- * A TypeSpec default (`basis: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
983
- * the field optional on the way in. Dropping it turned an optional field with a fallback into a
984
- * required one - callers omitting it would start getting a 400 for a request that used to work.
985
- */
986
- const defaulted = defaultOf(program, property);
987
- if (defaulted !== undefined)
988
- return `${base}.default(${defaulted})`;
989
981
  /**
990
982
  * **Optionality is a function of the position too, not only of the `?` in the spec.**
991
983
  *
@@ -995,7 +987,41 @@ export function propertyToZod(program, property) {
995
987
  * would reject every partial update the contract invites. `metadataInfo.isOptional` is the same
996
988
  * predicate `@typespec/openapi3` uses to decide the `required` list.
997
989
  */
998
- return property.optional || isOptionalAt(program, property) ? `${base}.optional()` : base;
990
+ const optional = property.optional || isOptionalAt(program, property);
991
+ /**
992
+ * **A default only applies where the document says the property may be absent.**
993
+ *
994
+ * `.default(v)` makes a Zod field optional on the way IN, so emitting one for a property the
995
+ * document publishes as `required` made this validator accept a request the document beside it
996
+ * refuses. `default` is an ANNOTATION under JSON Schema 2020-12 and `required` is the assertion;
997
+ * `@typespec/openapi3` reads the two exactly that way, building `required` from
998
+ * `metadataInfo.isOptional` and never consulting a default. See `default-on-required-property`
999
+ * in `lib.ts` for the specification chain and for why this warns rather than refuses.
1000
+ *
1001
+ * Reported and then rendered as an ordinary required property - the declared shape is untouched
1002
+ * and only the dead fallback is dropped, which is the same shape `unsupported-default` already
1003
+ * takes. `defaultOf` is deliberately NOT called on this path: it would report a second diagnostic
1004
+ * about a value that cannot be reached either way, naming the less useful of the two problems.
1005
+ */
1006
+ if (!optional && property.defaultValue !== undefined) {
1007
+ reportDiagnostic(program, {
1008
+ code: "default-on-required-property",
1009
+ target: property,
1010
+ format: { name: property.name },
1011
+ });
1012
+ return base;
1013
+ }
1014
+ /**
1015
+ * A TypeSpec default (`basis?: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
1016
+ * the field optional on the way in - matching a document that leaves the property out of
1017
+ * `required` and annotates it with the value. `.optional()` is not added beside it: `.default()`
1018
+ * already accepts an absent value, and the two together would make the OUTPUT optional as well,
1019
+ * so the fallback could be skipped.
1020
+ */
1021
+ const defaulted = defaultOf(program, property);
1022
+ if (defaulted !== undefined)
1023
+ return `${base}.default(${defaulted})`;
1024
+ return optional ? `${base}.optional()` : base;
999
1025
  }
1000
1026
  /**
1001
1027
  * A default value as a JS literal, or `undefined` where this emitter cannot render one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "codegen",