typespec-http-zod 0.22.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,13 +1298,53 @@ 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}`
1284
1305
  : `export type ${d.typeName} = ${d.structural};`;
1285
- const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}>` : "";
1286
- return `\n${declared}\nexport const ${d.identifier}${annotation} = ${d.source};\n`;
1306
+ /**
1307
+ * **BOTH of Zod's type parameters, and naming only the first left the second as `unknown`.**
1308
+ *
1309
+ * `z.ZodType<T>` is `ZodType<Output = T, Input = unknown>`, so a schema annotated with one
1310
+ * parameter reports `z.input<typeof x>` as `unknown` - and every schema that REFERENCES it
1311
+ * inherits that. Measured: `Identical<Declared<z.input<typeof nodeSchema>>, Contracts.Node>`
1312
+ * failed on `branch?: unknown | undefined` against `branch?: Branch | undefined`, in a fixture
1313
+ * whose two artefacts agree about every property. The wire assertion pairs `z.input` because
1314
+ * that is what a caller supplies, so an unannotated input parameter takes the whole cyclic
1315
+ * corner of the emitter out of that comparison.
1316
+ *
1317
+ * **One structural type serves both positions, and for a defaulted property on a cycle it is
1318
+ * the INPUT shape that both get.** A default is the only construct where a schema's input and
1319
+ * output types differ, and a cyclic declaration has exactly one type written out to name - the
1320
+ * loop is what stops `z.infer` being usable at all. Claiming the input shape in the output
1321
+ * position is the PERMISSIVE direction: every value that arrives still satisfies it, a reader
1322
+ * simply learns less than it could about a defaulted property. Claiming the output shape in
1323
+ * the input position is the restrictive one, and it is the defect this release exists to
1324
+ * remove - a caller told to supply what the document says they may omit.
1325
+ *
1326
+ * So the limitation is confined to this: a model on a cycle carrying a defaulted optional
1327
+ * property exports a type from `schemas.gen.ts` that is weaker than what arrives, by exactly
1328
+ * that property. `typespec-hono` derives its handler input from `z.infer` of the schema rather
1329
+ * than from this type, so no handler signature is affected.
1330
+ */
1331
+ const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}, ${d.typeName}>` : "";
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`;
1287
1348
  });
1288
1349
  /**
1289
1350
  * **Written only when something in the file names it**, like the `ResponseArm` import below and for
@@ -1302,37 +1363,24 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1302
1363
  * rendered content for the same reason.
1303
1364
  */
1304
1365
  /**
1305
- * **`Exact` narrows an inferred optional to what the wire can carry.**
1306
- *
1307
- * `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
1308
- * admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
1309
- * same thing by leaving the property out of `required`, so the inferred type is wider than the
1310
- * wire and a consumer reading a payload had to strip it at the boundary.
1366
+ * **`Exact<>` lived here until `0.24.0`, and `z.exactOptional()` replaced it.**
1311
1367
  *
1312
- * **It does NOT match the contract type, and this docblock used to claim it did.** The
1313
- * contract types in `requests.gen.ts` keep `?: T | undefined` on purpose: they are the floor a
1314
- * PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so refusing
1315
- * it would cost every producer a conditional spread and buy the wire nothing. Measured when this
1316
- * was "fixed" to agree: `(ctx, input) => ok(input)` - return what you were given - stopped
1317
- * compiling.
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.
1318
1372
  *
1319
- * So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
1320
- * one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
1321
- * 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.
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.
1323
1378
  *
1324
- * Derived from the same schema rather than hand-written beside it, so there is still one source of
1325
- * truth: a mapped type cannot drift from the thing it maps.
1326
- *
1327
- * 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)`.
1328
1383
  */
1329
- const EXACT_HELPER = `
1330
- /** An inferred type narrowed to what JSON can carry: an optional property is absent or a value. */
1331
- type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } & {
1332
- [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>;
1333
- };
1334
- `;
1335
- const usesExact = declarations.some((d) => d.includes("Exact<"));
1336
1384
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1337
1385
  const parts = [generatedBanner()];
1338
1386
  if (usesZod)
@@ -1349,8 +1397,6 @@ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } &
1349
1397
  parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
1350
1398
  }
1351
1399
  parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
1352
- if (usesExact)
1353
- parts.push(EXACT_HELPER);
1354
1400
  parts.push(declarations.join(""));
1355
1401
  if (routeDeclarations.length > 0)
1356
1402
  parts.push(`\n${routeDeclarations.join("\n")}\n`);
@@ -1367,7 +1413,7 @@ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } &
1367
1413
  const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
1368
1414
  const schemaConst = (operationId, suffix) => `${operationId}${suffix}`;
1369
1415
  /** `[the name to refer to it by, the `const` line to emit]` - the line is absent when none is needed. */
1370
- function namedSchema(operationId, suffix, expression) {
1416
+ function namedSchema(operationId, suffix, expression, compileSchemas) {
1371
1417
  if (expression === undefined)
1372
1418
  return { name: undefined, declaration: undefined };
1373
1419
  if (isIdentifier(expression))
@@ -1384,7 +1430,7 @@ function namedSchema(operationId, suffix, expression) {
1384
1430
  * Exporting also gives an application the request shape by name, which is what the per-operation
1385
1431
  * type aliases will be built from.
1386
1432
  */
1387
- return { name, declaration: `export const ${name} = ${expression};` };
1433
+ return { name, declaration: `export const ${name} = ${compiled(expression, compileSchemas)};` };
1388
1434
  }
1389
1435
  /**
1390
1436
  * Which `verb path` slots carry more than one operation.
@@ -1426,13 +1472,13 @@ function sharedSlots(routes) {
1426
1472
  * Declaring them here dissolves all three: the library mints the names AND emits them, and a server
1427
1473
  * emitter imports what it is told rather than agreeing about it.
1428
1474
  */
1429
- function nameRouteSchemas(routes) {
1475
+ function nameRouteSchemas(routes, compileSchemas) {
1430
1476
  const shared = sharedSlots(routes);
1431
1477
  const declarations = [];
1432
1478
  const names = new Map();
1433
1479
  for (const route of routes) {
1434
1480
  const declare = (suffix, expression) => {
1435
- const resolved = namedSchema(route.operationId, suffix, expression);
1481
+ const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas);
1436
1482
  if (resolved.declaration !== undefined)
1437
1483
  declarations.push(resolved.declaration);
1438
1484
  return resolved.name;
@@ -1795,8 +1841,26 @@ ${responseLookup}
1795
1841
  * both look right in isolation. That happened during this emitter's first run: a vocabulary in a
1796
1842
  * query-parameter position was inlined by one walk and referenced by the other.
1797
1843
  *
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.
1844
+ * **`z.input`, not `z.infer`, and the difference is the whole caller-side question.** `z.infer` is
1845
+ * the OUTPUT type - what a handler receives, after `.default(...)` has fired. These contract types
1846
+ * are the floor a PRODUCER supplies, and for a request the producer is the caller, who has not run
1847
+ * the validator. `z.input` is what the validator ACCEPTS, which is what a request contract states
1848
+ * and what the document's `required` list says. Measured on zod 4.4.3: `.default()` is the only
1849
+ * construct this emitter emits where the two differ - `.optional()`, `.nullable()` and every
1850
+ * constraint infer identically on both sides.
1851
+ *
1852
+ * Once `default-on-required-property` has removed the incoherent spelling, `z.input` and the
1853
+ * document's `required` list agree property for property, so this assertion cross-checks that rule
1854
+ * rather than merely restating one side of it.
1855
+ *
1856
+ * **One measured exception, which does not reach here.** `.refine()` with a type predicate narrows
1857
+ * the OUTPUT only - zod types it `this & ZodType<R, core.input<this>>` - so `z.input` of a
1858
+ * `HttpPart<File>` part is `unknown` where `z.infer` is the narrowed file shape. A multipart body's
1859
+ * contract type is an inline object literal rather than a named declaration, so it is never in the
1860
+ * paired set below; if that ever changes, this is the pairing that breaks first.
1861
+ *
1862
+ * `Equals` here, not assignability: the two describe one shape, and a property present on one side
1863
+ * and absent from the other is exactly the drift this exists to catch.
1800
1864
  */
1801
1865
  function renderWireAssertions(schemas, types, entries, contractsPackage) {
1802
1866
  // The assertions pair the emitted Zod against the request TYPES, which live in the contracts
@@ -1818,7 +1882,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
1818
1882
  }
1819
1883
  if (paired.length === 0)
1820
1884
  return undefined;
1821
- const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.infer<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
1885
+ const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
1822
1886
  return `${generatedBanner()}
1823
1887
  import type { z } from "zod";
1824
1888
  import type * as Contracts from "${contractsPackage}";
@@ -1868,6 +1932,26 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
1868
1932
  * dropped from a shape that has other keys, and kept - with its value type still mapped - from one
1869
1933
  * that does not.
1870
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
+
1871
1955
  type DeclaredKeyOf<T> = keyof {
1872
1956
  [K in keyof T as string extends K
1873
1957
  ? never
@@ -1886,7 +1970,9 @@ type Declared<T> = T extends (...args: never[]) => unknown
1886
1970
  : readonly Declared<Element>[]
1887
1971
  : T extends object
1888
1972
  ? [DeclaredKeyOf<T>] extends [never]
1889
- ? { [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]> }
1890
1976
  : {
1891
1977
  [K in keyof T as string extends K
1892
1978
  ? never
@@ -1894,7 +1980,7 @@ type Declared<T> = T extends (...args: never[]) => unknown
1894
1980
  ? never
1895
1981
  : symbol extends K
1896
1982
  ? never
1897
- : K]: Declared<T[K]>;
1983
+ : K]: Supplied<T, K>;
1898
1984
  }
1899
1985
  : T;
1900
1986
 
@@ -2032,6 +2118,7 @@ export async function emitHttpZod(context, wrapper = {}) {
2032
2118
  contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
2033
2119
  contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
2034
2120
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2121
+ compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
2035
2122
  keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
2036
2123
  runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
2037
2124
  regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
@@ -2088,10 +2175,10 @@ export async function emitHttpZod(context, wrapper = {}) {
2088
2175
  * anything is written - and it has to happen HERE rather than in a server emitter, or the
2089
2176
  * validators it produces never reach this package's own output.
2090
2177
  */
2091
- const { names, declarations } = nameRouteSchemas(routes);
2178
+ const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
2092
2179
  await emitFile(context.program, {
2093
2180
  path: resolvePath(outputDir, "schemas.gen.ts"),
2094
- content: renderSchemas(registry, externals, declarations, resolved.runtimeModule),
2181
+ content: renderSchemas(registry, externals, declarations, resolved.runtimeModule, resolved.compileSchemas),
2095
2182
  });
2096
2183
  if (resolved.contractsOutputDir !== undefined) {
2097
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;
@@ -119,6 +152,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
119
152
  "unsupported-default": {
120
153
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
121
154
  };
155
+ "default-on-required-property": {
156
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
157
+ };
122
158
  "undeclared-discriminator": {
123
159
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
124
160
  };
@@ -141,13 +177,16 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
141
177
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
142
178
  };
143
179
  }, 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 {
180
+ 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
181
  "unsupported-type": {
146
182
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
147
183
  };
148
184
  "unsupported-default": {
149
185
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
150
186
  };
187
+ "default-on-required-property": {
188
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
189
+ };
151
190
  "undeclared-discriminator": {
152
191
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
153
192
  };
@@ -176,6 +215,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
176
215
  "unsupported-default": {
177
216
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
178
217
  };
218
+ "default-on-required-property": {
219
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
220
+ };
179
221
  "undeclared-discriminator": {
180
222
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
181
223
  };
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 },
@@ -76,6 +78,42 @@ const diagnostics = {
76
78
  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
79
  },
78
80
  },
81
+ /**
82
+ * **A default on a REQUIRED property, where the default can never apply.**
83
+ *
84
+ * **`default` is an annotation and `required` is the assertion**, and this emitter used to
85
+ * implement the opposite. JSON Schema 2020-12 section 9.2 makes `default` an annotation keyword
86
+ * with no effect on validation, and `@typespec/openapi3` implements exactly that:
87
+ * `#requiredModelProperties` builds the `required` list from `metadataInfo.isOptional` alone and
88
+ * never consults a default, then attaches the default beside it as a plain `schema.default`. The
89
+ * parameter side is the same rule spelled differently, `required: !param.optional`.
90
+ *
91
+ * So `basis: string = "s1159"` publishes as REQUIRED with an annotation nothing can reach. This
92
+ * emitter emitted `.default("s1159")`, which makes the field optional on the way IN - a validator
93
+ * accepting a request the document generated beside it forbids. OAI/OpenAPI-Specification#1543 is
94
+ * closed COMPLETED on this exact question, with the JSON Schema editor's answer that writing a
95
+ * default in before validating "is **NOT** part of validation", and the OpenAPI maintainer's that
96
+ * a default beside `required` "risks making the `required` notion invalid and encourages clients
97
+ * to unnecessarily send `default` values over the wire". Swagger's own documentation lists the
98
+ * combination as one of the two common mistakes with the keyword.
99
+ *
100
+ * **A WARNING, and this package's first, because the spec is representable.** `unsupported-default`
101
+ * used to be an error on composite literals and was retired for precisely this reason: refusing a
102
+ * construct `@typespec/openapi3` emits makes the same spec representable by one emitter and not the
103
+ * other, which is the one thing a differential between the two cannot tolerate. openapi3 emits this
104
+ * one, so this emitter serves it - saying what the document says, and saying out loud that the
105
+ * annotation is dead. The fix is one character and the message names it.
106
+ *
107
+ * The predicate is the DOCUMENT's, `metadataInfo.isOptional`, so this fires exactly where openapi3
108
+ * writes the name into `required` - including the PATCH position, where an update body's properties
109
+ * are optional and a default on one of them is live rather than dead.
110
+ */
111
+ "default-on-required-property": {
112
+ severity: "warning",
113
+ messages: {
114
+ 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.`,
115
+ },
116
+ },
79
117
  /**
80
118
  * **A discriminated union whose discriminator the document never publishes.**
81
119
  *
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,59 @@ 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
+ /**
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;
999
1043
  }
1000
1044
  /**
1001
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.22.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": {