typespec-http-zod 0.22.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.js CHANGED
@@ -1282,7 +1282,32 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1282
1282
  const declared = d.structural.startsWith("{")
1283
1283
  ? `export interface ${d.typeName} ${d.structural}`
1284
1284
  : `export type ${d.typeName} = ${d.structural};`;
1285
- 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}>` : "";
1286
1311
  return `\n${declared}\nexport const ${d.identifier}${annotation} = ${d.source};\n`;
1287
1312
  });
1288
1313
  /**
@@ -1319,7 +1344,9 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1319
1344
  * So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
1320
1345
  * one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
1321
1346
  * may SEND. A reader comparing them will find them unequal and should - the emitted assertion
1322
- * 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.
1323
1350
  *
1324
1351
  * Derived from the same schema rather than hand-written beside it, so there is still one source of
1325
1352
  * truth: a mapped type cannot drift from the thing it maps.
@@ -1795,8 +1822,26 @@ ${responseLookup}
1795
1822
  * both look right in isolation. That happened during this emitter's first run: a vocabulary in a
1796
1823
  * query-parameter position was inlined by one walk and referenced by the other.
1797
1824
  *
1798
- * `Equals` here, not assignability - `z.infer` is the output type, and the wire type is emitted with
1799
- * 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.
1800
1845
  */
1801
1846
  function renderWireAssertions(schemas, types, entries, contractsPackage) {
1802
1847
  // The assertions pair the emitted Zod against the request TYPES, which live in the contracts
@@ -1818,7 +1863,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
1818
1863
  }
1819
1864
  if (paired.length === 0)
1820
1865
  return undefined;
1821
- 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}>>;`);
1822
1867
  return `${generatedBanner()}
1823
1868
  import type { z } from "zod";
1824
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
  *
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.22.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",