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 +2 -0
- package/dist/src/api.d.ts +2 -0
- package/dist/src/api.js +85 -43
- package/dist/src/constraints.d.ts +7 -6
- package/dist/src/constraints.js +7 -6
- package/dist/src/lib.d.ts +33 -0
- package/dist/src/lib.js +2 -0
- package/dist/src/zod.js +19 -1
- package/package.json +4 -3
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
|
-
|
|
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
|
-
|
|
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} =
|
|
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
|
-
|
|
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
|
|
1366
|
+
* **`Exact<>` lived here until `0.24.0`, and `z.exactOptional()` replaced it.**
|
|
1331
1367
|
*
|
|
1332
|
-
* `z.infer`
|
|
1333
|
-
*
|
|
1334
|
-
*
|
|
1335
|
-
*
|
|
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
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
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
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
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
|
-
?
|
|
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]:
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* other
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
/**
|
package/dist/src/constraints.js
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* other
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
73
|
+
"zod": "^4.5.0"
|
|
73
74
|
},
|
|
74
75
|
"peerDependenciesMeta": {
|
|
75
76
|
"@typespec/streams": {
|