typespec-http-zod 0.23.0 → 0.24.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/README.md CHANGED
@@ -148,6 +148,8 @@ carries the whole id, so `op readWidget` in `namespace Widgets` becomes `Widgets
148
148
  - [Reference](docs/reference.md): every option, every diagnostic, and the known limits.
149
149
  - [Oracles](docs/oracles.md): every artefact this emitter produces, and what compares it to the
150
150
  thing it has to agree with.
151
+ - [Releasing](docs/releasing.md): why this package publishes first, and how to rehearse a
152
+ two-package release against a local registry.
151
153
 
152
154
  ## Licence
153
155
 
package/dist/src/api.d.ts CHANGED
@@ -373,6 +373,8 @@ export interface ResolvedServiceOptions {
373
373
  readonly contractsOutputDir: string | undefined;
374
374
  readonly contractsPackage: string | undefined;
375
375
  readonly sealObjectSchemas: boolean;
376
+ /** Whether every emitted validator is wrapped in `z.compile()`. See the option's own docblock. */
377
+ readonly compileSchemas: boolean;
376
378
  readonly keyVocabularies: readonly string[];
377
379
  readonly runtimeModule: string;
378
380
  /** The command that regenerates, for the banner. `undefined` keeps the generic line. */
package/dist/src/api.js CHANGED
@@ -680,7 +680,9 @@ function multipartSchemaOf(body, registry) {
680
680
  : registry.expressionFor(type);
681
681
  // `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
682
682
  const value = part.multi === true ? `z.array(${inner})` : inner;
683
- return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.optional()` : value},`;
683
+ // `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
684
+ // only, so an absent part is an absent key rather than an explicit `undefined`.
685
+ return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.exactOptional()` : value},`;
684
686
  });
685
687
  const shape = entries.length === 0 ? "z.object({})" : `z.object({\n${entries.join("\n")}\n})`;
686
688
  /**
@@ -1266,7 +1268,26 @@ function renderExternalImports(externals, ...bodies) {
1266
1268
  * validate a request or a response against, and every oracle that graded those validators had to read
1267
1269
  * a server to find them. See {@link nameRouteSchemas}.
1268
1270
  */
1269
- function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1271
+ /**
1272
+ * **`z.compile(...)` around a validator, when `compile-schemas` asks for it.**
1273
+ *
1274
+ * Zod walks the schema once and emits a flat, loop-free function through `new Function`, falling back
1275
+ * to the ordinary parser for anything it cannot handle. Measured on zod 4.5.2 before this was written:
1276
+ * `z.toJSONSchema` output is byte-identical, `_zod.def` is unchanged and still readable, and verdicts
1277
+ * AND parsed output match for every construct this emitter writes - including `z.preprocess`,
1278
+ * `.exactOptional()` and a `z.lazy()` cycle. So the two oracles that read a schema rather than run it
1279
+ * are unaffected, and the wrapper is transparent.
1280
+ *
1281
+ * **A cyclic declaration is wrapped too, and that was worth checking rather than assuming.** The
1282
+ * emitted form names the const being defined from inside its own `z.lazy()` body, and this repository
1283
+ * has already shipped a break of exactly that shape - `.strict()` reads `shape` eagerly and throws
1284
+ * `Cannot access 'X' before initialization` during module initialisation. Measured on the emitted
1285
+ * shape: `z.compile()` does not force the lazy body, and the declaration parses.
1286
+ */
1287
+ function compiled(expression, on) {
1288
+ return on ? `z.compile(${expression})` : expression;
1289
+ }
1290
+ function renderSchemas(registry, externals, routeDeclarations, runtimeModule, compileSchemas) {
1270
1291
  const declarations = registry.declarations().map((d) => {
1271
1292
  /**
1272
1293
  * **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
@@ -1277,7 +1298,7 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1277
1298
  * with no cycle emits byte-identical output to before.
1278
1299
  */
1279
1300
  if (d.structural === undefined) {
1280
- return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = Exact<z.infer<typeof ${d.identifier}>>;\n`;
1301
+ return `\nexport const ${d.identifier} = ${compiled(d.source, compileSchemas)};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
1281
1302
  }
1282
1303
  const declared = d.structural.startsWith("{")
1283
1304
  ? `export interface ${d.typeName} ${d.structural}`
@@ -1308,7 +1329,22 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1308
1329
  * than from this type, so no handler signature is affected.
1309
1330
  */
1310
1331
  const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}, ${d.typeName}>` : "";
1311
- return `\n${declared}\nexport const ${d.identifier}${annotation} = ${d.source};\n`;
1332
+ /**
1333
+ * **A deferred declaration is NEVER compiled, and this was measured rather than reasoned.**
1334
+ *
1335
+ * `z.lazy()` is on Zod's own list of constructs the compiler cannot handle, and here the body
1336
+ * forward-references a `const` declared further down the module. Wrapping it emitted output that
1337
+ * loaded fine and then threw `Cannot read properties of undefined (reading '_zod')` on the first
1338
+ * parse - the walk captures the reference while it is still in the temporal dead zone, so the
1339
+ * failure surfaces at request time rather than at import.
1340
+ *
1341
+ * **Keyed on `annotated`, the fact the registry already holds** - it is set exactly when the
1342
+ * declaration is deferred, which is exactly when `z.lazy()` is written - rather than on the
1343
+ * shape of the emitted text. A model on a cycle whose back edge sits on a GETTER is a different
1344
+ * case and is compiled like any other: measured, it parses, because a getter is not resolved
1345
+ * until something reads it.
1346
+ */
1347
+ return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.annotated !== true)};\n`;
1312
1348
  });
1313
1349
  /**
1314
1350
  * **Written only when something in the file names it**, like the `ResponseArm` import below and for
@@ -1327,39 +1363,24 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1327
1363
  * rendered content for the same reason.
1328
1364
  */
1329
1365
  /**
1330
- * **`Exact` narrows an inferred optional to what the wire can carry.**
1366
+ * **`Exact<>` lived here until `0.24.0`, and `z.exactOptional()` replaced it.**
1331
1367
  *
1332
- * `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
1333
- * admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
1334
- * same thing by leaving the property out of `required`, so the inferred type is wider than the
1335
- * wire and a consumer reading a payload had to strip it at the boundary.
1368
+ * It was a mapped type over `z.infer` that stripped `| undefined` off every optional property,
1369
+ * because `.optional()` infers `p?: T | undefined` and **JSON has no `undefined`** - the document
1370
+ * says "absent or a value" by leaving the property out of `required`, so the inferred type was
1371
+ * wider than both the document and the wire.
1336
1372
  *
1337
- * **It does NOT match the contract type, and this docblock used to claim it did.** The
1338
- * contract types in `requests.gen.ts` keep `?: T | undefined` on purpose: they are the floor a
1339
- * PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so refusing
1340
- * it would cost every producer a conditional spread and buy the wire nothing. Measured when this
1341
- * was "fixed" to agree: `(ctx, input) => ok(input)` - return what you were given - stopped
1342
- * compiling.
1373
+ * The reasoning was right and it was applied to only one of the two artefacts. The VALIDATOR went
1374
+ * on accepting `{ p: undefined }`, so one program published a type refusing a value beside a
1375
+ * validator admitting it, compared by nothing. Emitting `.exactOptional()` makes `z.infer` produce
1376
+ * `p?: T` directly - at every depth, which a shallow mapped type never reached - so the narrowing
1377
+ * happens once, in the schema, and this helper has nothing left to do.
1343
1378
  *
1344
- * So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
1345
- * one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
1346
- * may SEND. A reader comparing them will find them unequal and should - the emitted assertion
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.
1350
- *
1351
- * Derived from the same schema rather than hand-written beside it, so there is still one source of
1352
- * truth: a mapped type cannot drift from the thing it maps.
1353
- *
1354
- * Emitted only where a declaration names it, like the imports beside it.
1379
+ * The contract types in `requests.gen.ts` still keep `?: T | undefined` on purpose: they are the
1380
+ * floor a PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so
1381
+ * refusing it there would cost every producer a conditional spread and buy the wire nothing.
1382
+ * Narrowing THAT surface was tried in `0.19.0` and reverted - it broke `(ctx, input) => ok(input)`.
1355
1383
  */
1356
- const EXACT_HELPER = `
1357
- /** An inferred type narrowed to what JSON can carry: an optional property is absent or a value. */
1358
- type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } & {
1359
- [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>;
1360
- };
1361
- `;
1362
- const usesExact = declarations.some((d) => d.includes("Exact<"));
1363
1384
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1364
1385
  const parts = [generatedBanner()];
1365
1386
  if (usesZod)
@@ -1376,8 +1397,6 @@ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } &
1376
1397
  parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
1377
1398
  }
1378
1399
  parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
1379
- if (usesExact)
1380
- parts.push(EXACT_HELPER);
1381
1400
  parts.push(declarations.join(""));
1382
1401
  if (routeDeclarations.length > 0)
1383
1402
  parts.push(`\n${routeDeclarations.join("\n")}\n`);
@@ -1394,7 +1413,7 @@ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } &
1394
1413
  const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
1395
1414
  const schemaConst = (operationId, suffix) => `${operationId}${suffix}`;
1396
1415
  /** `[the name to refer to it by, the `const` line to emit]` - the line is absent when none is needed. */
1397
- function namedSchema(operationId, suffix, expression) {
1416
+ function namedSchema(operationId, suffix, expression, compileSchemas) {
1398
1417
  if (expression === undefined)
1399
1418
  return { name: undefined, declaration: undefined };
1400
1419
  if (isIdentifier(expression))
@@ -1411,7 +1430,7 @@ function namedSchema(operationId, suffix, expression) {
1411
1430
  * Exporting also gives an application the request shape by name, which is what the per-operation
1412
1431
  * type aliases will be built from.
1413
1432
  */
1414
- return { name, declaration: `export const ${name} = ${expression};` };
1433
+ return { name, declaration: `export const ${name} = ${compiled(expression, compileSchemas)};` };
1415
1434
  }
1416
1435
  /**
1417
1436
  * Which `verb path` slots carry more than one operation.
@@ -1453,13 +1472,13 @@ function sharedSlots(routes) {
1453
1472
  * Declaring them here dissolves all three: the library mints the names AND emits them, and a server
1454
1473
  * emitter imports what it is told rather than agreeing about it.
1455
1474
  */
1456
- function nameRouteSchemas(routes) {
1475
+ function nameRouteSchemas(routes, compileSchemas) {
1457
1476
  const shared = sharedSlots(routes);
1458
1477
  const declarations = [];
1459
1478
  const names = new Map();
1460
1479
  for (const route of routes) {
1461
1480
  const declare = (suffix, expression) => {
1462
- const resolved = namedSchema(route.operationId, suffix, expression);
1481
+ const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas);
1463
1482
  if (resolved.declaration !== undefined)
1464
1483
  declarations.push(resolved.declaration);
1465
1484
  return resolved.name;
@@ -1913,6 +1932,26 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
1913
1932
  * dropped from a shape that has other keys, and kept - with its value type still mapped - from one
1914
1933
  * that does not.
1915
1934
  */
1935
+ /**
1936
+ * \u26a0\ufe0f **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
1937
+ * deliberate on both sides.**
1938
+ *
1939
+ * The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
1940
+ * \`undefined\` and the document says exactly that by leaving the property out of \`required\`. The
1941
+ * contract type is the floor a PRODUCER supplies, and \`{ p: undefined }\` serialises identically to
1942
+ * omitting \`p\`, so refusing it would cost every producer a conditional spread and buy the wire
1943
+ * nothing. Narrowing THAT surface was tried in \`0.19.0\` and reverted: it broke
1944
+ * \`(ctx, input) => ok(input)\`.
1945
+ *
1946
+ * So the two disagree on exactly one axis, on purpose, and this widens the inferred side to the
1947
+ * producer floor before comparing - the same job \`Declared\` does for openness, in the same pass
1948
+ * rather than a second deep one. Every other kind of drift still fails: a property that changes type,
1949
+ * appears, disappears or changes optionality is untouched by this.
1950
+ */
1951
+ type Supplied<T, K extends keyof T> = {} extends Pick<T, K>
1952
+ ? Declared<T[K]> | undefined
1953
+ : Declared<T[K]>;
1954
+
1916
1955
  type DeclaredKeyOf<T> = keyof {
1917
1956
  [K in keyof T as string extends K
1918
1957
  ? never
@@ -1931,7 +1970,9 @@ type Declared<T> = T extends (...args: never[]) => unknown
1931
1970
  : readonly Declared<Element>[]
1932
1971
  : T extends object
1933
1972
  ? [DeclaredKeyOf<T>] extends [never]
1934
- ? { [K in keyof T]: Declared<T[K]> }
1973
+ ? // A shape with no declared keys is a dictionary: it has no optional property to widen,
1974
+ // and an index signature satisfies \`{} extends Pick<T, K>\` for every value type.
1975
+ { [K in keyof T]: Declared<T[K]> }
1935
1976
  : {
1936
1977
  [K in keyof T as string extends K
1937
1978
  ? never
@@ -1939,7 +1980,7 @@ type Declared<T> = T extends (...args: never[]) => unknown
1939
1980
  ? never
1940
1981
  : symbol extends K
1941
1982
  ? never
1942
- : K]: Declared<T[K]>;
1983
+ : K]: Supplied<T, K>;
1943
1984
  }
1944
1985
  : T;
1945
1986
 
@@ -2077,6 +2118,7 @@ export async function emitHttpZod(context, wrapper = {}) {
2077
2118
  contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
2078
2119
  contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
2079
2120
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2121
+ compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
2080
2122
  keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
2081
2123
  runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
2082
2124
  regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
@@ -2133,10 +2175,10 @@ export async function emitHttpZod(context, wrapper = {}) {
2133
2175
  * anything is written - and it has to happen HERE rather than in a server emitter, or the
2134
2176
  * validators it produces never reach this package's own output.
2135
2177
  */
2136
- const { names, declarations } = nameRouteSchemas(routes);
2178
+ const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
2137
2179
  await emitFile(context.program, {
2138
2180
  path: resolvePath(outputDir, "schemas.gen.ts"),
2139
- content: renderSchemas(registry, externals, declarations, resolved.runtimeModule),
2181
+ content: renderSchemas(registry, externals, declarations, resolved.runtimeModule, resolved.compileSchemas),
2140
2182
  });
2141
2183
  if (resolved.contractsOutputDir !== undefined) {
2142
2184
  const types = new TypeRegistry(context.program);
@@ -6,12 +6,13 @@ import { type Program, type Type } from "@typespec/compiler";
6
6
  * over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
7
7
  * documents them without any help from us.
8
8
  *
9
- * **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint -
10
- * it is an arbitrary predicate, which a type language has no way to say and therefore no document
11
- * can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
12
- * other one and is gone: `.trim()` is a transform, and transforming a value before validating it
13
- * makes the runtime accept inputs the published contract rejects. The spec states `@pattern`
14
- * instead, which openapi3 publishes.
9
+ * **This package ships no decorators of its own, and the two it used to have are gone.** `@refine`
10
+ * was an arbitrary predicate, which a type language has no way to say and therefore no document can
11
+ * publish, so a validator enforcing it enforced something no caller could read. `@trimmed` was the
12
+ * other: `.trim()` is a transform, and transforming a value before validating it makes the runtime
13
+ * accept inputs the published contract rejects. The spec states `@pattern` instead, which openapi3
14
+ * publishes. `src/tsp-index.ts` exports `$lib` and no `$decorators`, and `vocabulary.test.ts`
15
+ * asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
15
16
  */
16
17
  export declare function applyConstraints(program: Program, expression: string, target: Type): string;
17
18
  /**
@@ -6,12 +6,13 @@ import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getM
6
6
  * over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
7
7
  * documents them without any help from us.
8
8
  *
9
- * **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint -
10
- * it is an arbitrary predicate, which a type language has no way to say and therefore no document
11
- * can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
12
- * other one and is gone: `.trim()` is a transform, and transforming a value before validating it
13
- * makes the runtime accept inputs the published contract rejects. The spec states `@pattern`
14
- * instead, which openapi3 publishes.
9
+ * **This package ships no decorators of its own, and the two it used to have are gone.** `@refine`
10
+ * was an arbitrary predicate, which a type language has no way to say and therefore no document can
11
+ * publish, so a validator enforcing it enforced something no caller could read. `@trimmed` was the
12
+ * other: `.trim()` is a transform, and transforming a value before validating it makes the runtime
13
+ * accept inputs the published contract rejects. The spec states `@pattern` instead, which openapi3
14
+ * publishes. `src/tsp-index.ts` exports `$lib` and no `$decorators`, and `vocabulary.test.ts`
15
+ * asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
15
16
  */
16
17
  export function applyConstraints(program, expression, target) {
17
18
  let result = expression;
package/dist/src/lib.d.ts CHANGED
@@ -42,6 +42,38 @@ export interface EmitterOptions {
42
42
  * stating it outright rather than an emitter option deciding.
43
43
  */
44
44
  "seal-object-schemas"?: boolean;
45
+ /**
46
+ * Whether every emitted validator is wrapped in `z.compile()`.
47
+ *
48
+ * **A pre-compiled schema parses the same values to the same results, only faster.** Zod walks the
49
+ * schema once and emits a flat, loop-free function through `new Function`, then falls back to the
50
+ * ordinary parser for anything it cannot handle - so errors, parsed output and JSON Schema
51
+ * serialisation are unchanged. Measured on an emitted five-property model at zod 4.5.2:
52
+ * `safeParse` goes from 371 ns to 51 ns.
53
+ *
54
+ * **Off by default, because it is a trade rather than a free win.** Compilation happens at module
55
+ * scope, so it costs startup time in proportion to the number of schemas, and buys nothing until
56
+ * enough requests arrive to pay that back. Measured on two emitted modules at zod 4.5.2: 25
57
+ * declarations in 3.3 ms and 73 in 5.7 ms, so 0.08-0.13 ms each depending on how much the schema
58
+ * does.
59
+ *
60
+ * **Measured at scale rather than extrapolated:** a generated 580-operation service (58 models,
61
+ * 1218 declarations, 638 of them compiled) evaluates its schema module in ~93 ms uncompiled and
62
+ * ~132 ms compiled - so about **40 ms of extra startup**, roughly a tenth of a Cloudflare Worker's
63
+ * 400 ms startup budget. It boots and serves on `workerd` at that size with the fast path live. It also needs
64
+ * `new Function`, which a CSP or no-eval environment refuses; Zod degrades to the uncompiled
65
+ * schema there rather than throwing, so setting this is never unsafe, merely sometimes pointless.
66
+ *
67
+ * **On Cloudflare Workers this is the only route to a compiled schema.** `new Function` is
68
+ * permitted during a Worker's STARTUP phase (`allow_eval_during_startup`, the default from
69
+ * compatibility date 2025-06-01) and module scope is startup, so wrapping here works. Zod's own
70
+ * `import "zod/compile"` does not: it compiles on first PARSE, which happens inside a request,
71
+ * where the runtime refuses. Only the emitter owns the module scope these schemas are built in.
72
+ *
73
+ * A recursive model emits `z.lazy()`, which cannot be compiled. Zod returns it uncompiled and it
74
+ * goes on parsing normally, so the option is safe to set on any spec.
75
+ */
76
+ "compile-schemas"?: boolean;
45
77
  /**
46
78
  * Models whose PROPERTY NAMES are also emitted as a runtime tuple.
47
79
  *
@@ -100,6 +132,7 @@ export interface EmitterOptions {
100
132
  "contracts-output-dir"?: string;
101
133
  "contracts-package"?: string;
102
134
  "seal-object-schemas"?: boolean;
135
+ "compile-schemas"?: boolean;
103
136
  "key-vocabularies"?: string[];
104
137
  "runtime-module"?: string;
105
138
  "regenerate-hint"?: string;
package/dist/src/lib.js CHANGED
@@ -12,6 +12,7 @@ export const EmitterOptionsSchema = {
12
12
  "contracts-output-dir": { type: "string", nullable: true },
13
13
  "contracts-package": { type: "string", nullable: true },
14
14
  "seal-object-schemas": { type: "boolean", nullable: true },
15
+ "compile-schemas": { type: "boolean", nullable: true },
15
16
  "key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
16
17
  "runtime-module": { type: "string", nullable: true },
17
18
  "regenerate-hint": { type: "string", nullable: true },
@@ -26,6 +27,7 @@ export const EmitterOptionsSchema = {
26
27
  "contracts-output-dir": { type: "string", nullable: true },
27
28
  "contracts-package": { type: "string", nullable: true },
28
29
  "seal-object-schemas": { type: "boolean", nullable: true },
30
+ "compile-schemas": { type: "boolean", nullable: true },
29
31
  "key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
30
32
  "runtime-module": { type: "string", nullable: true },
31
33
  "regenerate-hint": { type: "string", nullable: true },
package/dist/src/zod.js CHANGED
@@ -1021,7 +1021,25 @@ export function propertyToZod(program, property) {
1021
1021
  const defaulted = defaultOf(program, property);
1022
1022
  if (defaulted !== undefined)
1023
1023
  return `${base}.default(${defaulted})`;
1024
- return optional ? `${base}.optional()` : base;
1024
+ /**
1025
+ * **`.exactOptional()`, because the document says the KEY may be absent and nothing more.**
1026
+ *
1027
+ * `.optional()` additionally accepts an explicit `undefined`, and **JSON cannot carry one** - so
1028
+ * for a body the validator was admitting a value no conformant request can contain, and for a
1029
+ * parameter one no transport produces. The document says exactly "absent or a value" by leaving
1030
+ * the property out of `required`, and this is the construct that says the same.
1031
+ *
1032
+ * **The emitted TYPE has said this since `0.17.0`; only the validator disagreed.** `Exact<>` was
1033
+ * written to strip `| undefined` back off `z.infer`, with a docblock arguing precisely this - so
1034
+ * one program published a type refusing `{ note: undefined }` beside a validator accepting it, and
1035
+ * nothing compared them. `z.infer` of `.exactOptional()` is `note?: T` natively and at every depth,
1036
+ * which a shallow mapped type never managed, so `Exact<>` is deleted rather than kept beside it.
1037
+ *
1038
+ * Measured on zod 4.5.2 before the change: `z.toJSONSchema` is byte-identical for the two, so the
1039
+ * differential's serialisation axis is untouched, and `def.type` is still `"optional"`, so the
1040
+ * shape describers read it unchanged.
1041
+ */
1042
+ return optional ? `${base}.exactOptional()` : base;
1025
1043
  }
1026
1044
  /**
1027
1045
  * 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.23.0",
3
+ "version": "0.24.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",
@@ -55,13 +55,14 @@
55
55
  "@typespec/streams": "0.85.0",
56
56
  "@typespec/versioning": "0.85.0",
57
57
  "@typespec/xml": "0.85.0",
58
+ "ajv": "^8.20.0",
58
59
  "oxfmt": "^0.63.0",
59
60
  "oxlint": "^1.78.0",
60
61
  "typescript": "~7.0.2",
61
62
  "typespec-http-zod": "link:.",
62
63
  "vitest": "^4.1.9",
63
64
  "yaml": "^2.9.0",
64
- "zod": "^4.4.3"
65
+ "zod": "^4.5.2"
65
66
  },
66
67
  "peerDependencies": {
67
68
  "@typespec/compiler": "^1.15.0",
@@ -69,7 +70,7 @@
69
70
  "@typespec/openapi": "^1.15.0",
70
71
  "@typespec/streams": "^0.85.0",
71
72
  "@typespec/versioning": "^0.85.0",
72
- "zod": "^4.0.0"
73
+ "zod": "^4.5.0"
73
74
  },
74
75
  "peerDependenciesMeta": {
75
76
  "@typespec/streams": {