typespec-http-zod 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/api.d.ts CHANGED
@@ -43,6 +43,7 @@ export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
43
43
  * independently.
44
44
  */
45
45
  export { objectKey } from "./zod.js";
46
+ export declare function beginBanner(hint: string | undefined): void;
46
47
  /**
47
48
  * The status a row registers: the first non-error status the operation declares.
48
49
  *
@@ -328,6 +329,8 @@ export interface ResolvedServiceOptions {
328
329
  readonly sealObjectSchemas: boolean;
329
330
  readonly keyVocabularies: readonly string[];
330
331
  readonly runtimeModule: string;
332
+ /** The command that regenerates, for the banner. `undefined` keeps the generic line. */
333
+ readonly regenerateHint: string | undefined;
331
334
  }
332
335
  /** What the generated files import their runtime contract from when nothing says otherwise. */
333
336
  export declare const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
package/dist/src/api.js CHANGED
@@ -60,9 +60,30 @@ const PACKAGE_NAME = "typespec-http-zod";
60
60
  * The package name stays, because that IS the answer to "what wrote this". The instruction becomes
61
61
  * the one thing true for every consumer: recompile the source, do not edit the output.
62
62
  */
63
- const GENERATED_BANNER = `// GENERATED by ${PACKAGE_NAME} from the TypeSpec service definition. DO NOT EDIT.
64
- // Recompile the spec that produced it; edits here are overwritten on the next run.
65
- `;
63
+ /**
64
+ * How to regenerate, for the banner. Set per emission; `undefined` keeps the generic line.
65
+ *
66
+ * Module-scoped rather than threaded through four render functions, matching how the contracts
67
+ * package and the stream module are carried. The emission loop awaits each service to completion
68
+ * before the next begins, so nothing interleaves.
69
+ */
70
+ let regenerateHint;
71
+ export function beginBanner(hint) {
72
+ regenerateHint = hint;
73
+ }
74
+ /**
75
+ * The header every emitted file carries.
76
+ *
77
+ * **`DO NOT EDIT` says what not to do and not what to do instead**, and only the project knows the
78
+ * command that regenerates. Where it states one, the banner carries it; otherwise the generic line
79
+ * stands.
80
+ */
81
+ function generatedBanner() {
82
+ const second = regenerateHint === undefined
83
+ ? "// Recompile the spec that produced it; edits here are overwritten on the next run."
84
+ : `// Regenerate with: ${regenerateHint}`;
85
+ return `// GENERATED by ${PACKAGE_NAME} from the TypeSpec service definition. DO NOT EDIT.\n${second}\n`;
86
+ }
66
87
  /**
67
88
  * The status a row registers: the first non-error status the operation declares.
68
89
  *
@@ -1110,7 +1131,7 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1110
1131
  * with no cycle emits byte-identical output to before.
1111
1132
  */
1112
1133
  if (d.structural === undefined) {
1113
- return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
1134
+ return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = Exact<z.infer<typeof ${d.identifier}>>;\n`;
1114
1135
  }
1115
1136
  const declared = d.structural.startsWith("{")
1116
1137
  ? `export interface ${d.typeName} ${d.structural}`
@@ -1134,8 +1155,28 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1134
1155
  * inside `zValidator` or missed beside a bracket. `renderExternalImports` below reads the same
1135
1156
  * rendered content for the same reason.
1136
1157
  */
1158
+ /**
1159
+ * **`Exact` narrows an inferred optional to what the wire can carry.**
1160
+ *
1161
+ * `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
1162
+ * admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
1163
+ * same thing by leaving the property out of `required`, so the inferred type was wider than both
1164
+ * the contract and the wire and a consumer had to strip it at the boundary.
1165
+ *
1166
+ * Derived from the same schema rather than hand-written beside it, so there is still one source of
1167
+ * truth: a mapped type cannot drift from the thing it maps.
1168
+ *
1169
+ * Emitted only where a declaration names it, like the imports beside it.
1170
+ */
1171
+ const EXACT_HELPER = `
1172
+ /** An inferred type narrowed to what JSON can carry: an optional property is absent or a value. */
1173
+ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } & {
1174
+ [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>;
1175
+ };
1176
+ `;
1177
+ const usesExact = declarations.some((d) => d.includes("Exact<"));
1137
1178
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1138
- const parts = [GENERATED_BANNER];
1179
+ const parts = [generatedBanner()];
1139
1180
  if (usesZod)
1140
1181
  parts.push('\nimport { z } from "zod";\n');
1141
1182
  /**
@@ -1150,6 +1191,8 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1150
1191
  parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
1151
1192
  }
1152
1193
  parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
1194
+ if (usesExact)
1195
+ parts.push(EXACT_HELPER);
1153
1196
  parts.push(declarations.join(""));
1154
1197
  if (routeDeclarations.length > 0)
1155
1198
  parts.push(`\n${routeDeclarations.join("\n")}\n`);
@@ -1317,7 +1360,28 @@ function responseArmsOf(route, response, alternate) {
1317
1360
  * declares. Each arm resolves its own body now, so an arm with no schema is a response the document
1318
1361
  * says carries no body, and saying `undefined` is the truthful answer rather than a guess.
1319
1362
  */
1363
+ /**
1364
+ * **An error arm whose status the primary arm already answers is dropped, primary winning.**
1365
+ *
1366
+ * `errorArmsOf` excludes 2xx from the failure arms, so widening the primary to include 3xx made a
1367
+ * redirect BOTH: `[{ status: 302, schema: undefined, headers: [...] }, { status: 302, schema:
1368
+ * undefined }]`, two arms for one status where the second shadows the first's headers for anything
1369
+ * scanning past the first match.
1370
+ *
1371
+ * Widening `isSuccessKey` to match would be wrong in the other direction: an operation declaring a
1372
+ * 200 AND a 302 has 200 as its primary, so the 302 must stay a failure arm to be emitted at all.
1373
+ * Deduplicating here keeps both cases, because it asks what was actually emitted rather than what
1374
+ * might have been.
1375
+ */
1376
+ const emitted = new Set();
1377
+ for (const arm of arms) {
1378
+ const status = /status: ([^,}]+)/.exec(arm)?.[1]?.trim();
1379
+ if (status !== undefined)
1380
+ emitted.add(status);
1381
+ }
1320
1382
  for (const arm of route.errorArms) {
1383
+ if (emitted.has(String(arm.status)) || emitted.has(JSON.stringify(arm.status)))
1384
+ continue;
1321
1385
  arms.push(`{ status: ${JSON.stringify(arm.status)}, schema: ${arm.schema ?? "undefined"} }`);
1322
1386
  }
1323
1387
  return `[${arms.join(", ")}]`;
@@ -1468,7 +1532,7 @@ function renderRequestTypes(entries, responses, registry) {
1468
1532
  const lookup = entries
1469
1533
  .map((entry) => `\t${entry.operationId}: ${upperFirst(entry.operationId)}Input;`)
1470
1534
  .join("\n");
1471
- return `${GENERATED_BANNER}
1535
+ return `${generatedBanner()}
1472
1536
  /**
1473
1537
  * Flattens an intersection into a single object type.
1474
1538
  *
@@ -1549,7 +1613,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
1549
1613
  if (paired.length === 0)
1550
1614
  return undefined;
1551
1615
  const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<z.infer<typeof ${pair.identifier}>, Contracts.${pair.name}>>;`);
1552
- return `${GENERATED_BANNER}
1616
+ return `${generatedBanner()}
1553
1617
  import type { z } from "zod";
1554
1618
  import type * as Contracts from "${contractsPackage}";
1555
1619
  import type {
@@ -1587,7 +1651,7 @@ function renderVocabularies(vocabularies) {
1587
1651
  const entries = [...vocabularies.entries()]
1588
1652
  .toSorted(([a], [b]) => a.localeCompare(b))
1589
1653
  .map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
1590
- return `${GENERATED_BANNER}
1654
+ return `${generatedBanner()}
1591
1655
  /**
1592
1656
  * The spec's vocabularies - every enum the surface reaches, and what each one contains.
1593
1657
  *
@@ -1671,7 +1735,9 @@ export async function emitHttpZod(context, wrapper = {}) {
1671
1735
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
1672
1736
  keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
1673
1737
  runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
1738
+ regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
1674
1739
  };
1740
+ beginBanner(resolved.regenerateHint);
1675
1741
  const registry = new SchemaRegistry(context.program);
1676
1742
  const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
1677
1743
  // Declarations first: a vocabulary is a fact about the contract, so it is collected from
package/dist/src/lib.d.ts CHANGED
@@ -78,6 +78,15 @@ export interface EmitterOptions {
78
78
  * package's own tests, where the package name does not resolve.
79
79
  */
80
80
  "runtime-module"?: string;
81
+ /**
82
+ * The command that regenerates these files, written into every banner.
83
+ *
84
+ * **`DO NOT EDIT` says what not to do and not what to do instead.** Only the project knows whether
85
+ * that is `pnpm generate`, `npm run api` or a `tsp compile` with three flags, so no emitter can
86
+ * supply it, and the generic line standing in its place lost the one thing a reader opening a
87
+ * generated file needs. Omitted, the generic line is kept.
88
+ */
89
+ "regenerate-hint"?: string;
81
90
  /**
82
91
  * Per-`@service` overrides, keyed by the service namespace name.
83
92
  *
@@ -93,6 +102,7 @@ export interface EmitterOptions {
93
102
  "seal-object-schemas"?: boolean;
94
103
  "key-vocabularies"?: string[];
95
104
  "runtime-module"?: string;
105
+ "regenerate-hint"?: string;
96
106
  }>;
97
107
  }
98
108
  /**
package/dist/src/lib.js CHANGED
@@ -14,6 +14,7 @@ export const EmitterOptionsSchema = {
14
14
  "seal-object-schemas": { type: "boolean", nullable: true },
15
15
  "key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
16
16
  "runtime-module": { type: "string", nullable: true },
17
+ "regenerate-hint": { type: "string", nullable: true },
17
18
  services: {
18
19
  type: "object",
19
20
  nullable: true,
@@ -27,6 +28,7 @@ export const EmitterOptionsSchema = {
27
28
  "seal-object-schemas": { type: "boolean", nullable: true },
28
29
  "key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
29
30
  "runtime-module": { type: "string", nullable: true },
31
+ "regenerate-hint": { type: "string", nullable: true },
30
32
  },
31
33
  required: [],
32
34
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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",