typespec-http-zod 0.18.0 → 0.19.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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, resolvePath, } from "@typespec/compiler";
1
+ import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
2
2
  import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
@@ -1260,8 +1260,20 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1260
1260
  *
1261
1261
  * `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
1262
1262
  * admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
1263
- * same thing by leaving the property out of `required`, so the inferred type was wider than both
1264
- * the contract and the wire and a consumer had to strip it at the boundary.
1263
+ * same thing by leaving the property out of `required`, so the inferred type is wider than the
1264
+ * wire and a consumer reading a payload had to strip it at the boundary.
1265
+ *
1266
+ * **It does NOT match the contract type, and this docblock used to claim it did.** The
1267
+ * contract types in `requests.gen.ts` keep `?: T | undefined` on purpose: they are the floor a
1268
+ * PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so refusing
1269
+ * it would cost every producer a conditional spread and buy the wire nothing. Measured when this
1270
+ * was "fixed" to agree: `(ctx, input) => ok(input)` - return what you were given - stopped
1271
+ * compiling.
1272
+ *
1273
+ * So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
1274
+ * one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
1275
+ * may SEND. A reader comparing them will find them unequal and should - the emitted assertion
1276
+ * pairs the contract against raw `z.infer`, not against this.
1265
1277
  *
1266
1278
  * Derived from the same schema rather than hand-written beside it, so there is still one source of
1267
1279
  * truth: a mapped type cannot drift from the thing it maps.
@@ -1663,8 +1675,14 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
1663
1675
  * No branch for functions, deliberately: the wire vocabulary is primitives, objects and arrays, so
1664
1676
  * a call signature cannot appear here. Mapping one would yield \`{}\`, so the day something does
1665
1677
  * carry a method - a narrowed multipart file part is the near miss - this needs one.
1678
+ *
1679
+ * **Exported, because a consumer needs it for a NAMED type and could not reach it.** It was applied
1680
+ * to \`WireOutputs\` and declared locally, so a codebase whose layers hand back \`readonly T[]\` had
1681
+ * the producer view sitting in the file it was already importing and no way to name it. Measured on
1682
+ * one service: 2 contract methods returning \`readonly T[]\` and 35 readonly array properties, none
1683
+ * of them expressible against the published shape.
1666
1684
  */
1667
- type Produced<T> = T extends readonly (infer Element)[]
1685
+ export type Produced<T> = T extends readonly (infer Element)[]
1668
1686
  ? readonly Produced<Element>[]
1669
1687
  : T extends object
1670
1688
  ? { readonly [K in keyof T]: Produced<T[K]> }
@@ -1762,7 +1780,29 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
1762
1780
  *
1763
1781
  * Deep, because openness recurs: a nested open model puts one at every level, which is why a single
1764
1782
  * spread never repaired the shape it was supposed to repair.
1783
+ *
1784
+ * **An index signature arrives two ways and only one of them is openness.** A model spreading
1785
+ * \`...Record<unknown>\` gains one because its validator is loose - a fact about the MODEL, which the
1786
+ * contract type does not state, so it is stripped. A property declared \`Record<unknown>\` gains one
1787
+ * because that IS its type, stated identically in both artefacts, and stripping it destroys the
1788
+ * property: \`Declared<Record<string, unknown>>\` became \`{}\`, and any spec with a dictionary
1789
+ * property emitted an assertion that does not compile.
1790
+ *
1791
+ * The two are told apart the same way \`types.ts\` already tells them apart: **a string indexer means
1792
+ * "dictionary" only when there are no declared properties beside it.** So the index signature is
1793
+ * dropped from a shape that has other keys, and kept - with its value type still mapped - from one
1794
+ * that does not.
1765
1795
  */
1796
+ type DeclaredKeyOf<T> = keyof {
1797
+ [K in keyof T as string extends K
1798
+ ? never
1799
+ : number extends K
1800
+ ? never
1801
+ : symbol extends K
1802
+ ? never
1803
+ : K]: 0;
1804
+ };
1805
+
1766
1806
  type Declared<T> = T extends (...args: never[]) => unknown
1767
1807
  ? T
1768
1808
  : T extends readonly (infer Element)[]
@@ -1770,15 +1810,17 @@ type Declared<T> = T extends (...args: never[]) => unknown
1770
1810
  ? Declared<Element>[]
1771
1811
  : readonly Declared<Element>[]
1772
1812
  : T extends object
1773
- ? {
1774
- [K in keyof T as string extends K
1775
- ? never
1776
- : number extends K
1813
+ ? [DeclaredKeyOf<T>] extends [never]
1814
+ ? { [K in keyof T]: Declared<T[K]> }
1815
+ : {
1816
+ [K in keyof T as string extends K
1777
1817
  ? never
1778
- : symbol extends K
1818
+ : number extends K
1779
1819
  ? never
1780
- : K]: Declared<T[K]>;
1781
- }
1820
+ : symbol extends K
1821
+ ? never
1822
+ : K]: Declared<T[K]>;
1823
+ }
1782
1824
  : T;
1783
1825
 
1784
1826
  /**
@@ -1872,6 +1914,36 @@ export async function emitHttpZod(context, wrapper = {}) {
1872
1914
  * there means a server emitter follows without knowing this rule exists.
1873
1915
  */
1874
1916
  const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
1917
+ /**
1918
+ * **Every service must seal the same way, because `@typespec/openapi3` has no per-service
1919
+ * options.**
1920
+ *
1921
+ * This option exists only so that two emitters answering the same question about the same models
1922
+ * give the same answer - it is stated twice on purpose, and its own docblock says to set it to
1923
+ * whatever openapi3 is set to. openapi3 applies ONE value to the whole program. So services that
1924
+ * seal differently cannot both be mirrored: whichever way openapi3 is configured, at least one
1925
+ * service publishes a document that disagrees with the validator generated beside it. Sealed here
1926
+ * and silent there refuses a payload the document permits; the reverse publishes a strictness the
1927
+ * runtime does not enforce.
1928
+ *
1929
+ * Reported once for the program rather than once per service, because the fault is the
1930
+ * DISAGREEMENT and no single service is the wrong one.
1931
+ */
1932
+ const sealings = new Map();
1933
+ for (const snapshot of snapshots) {
1934
+ const name = snapshot.service.namespace.name;
1935
+ const perService = options.services?.[name];
1936
+ sealings.set(name, perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false);
1937
+ }
1938
+ if (new Set(sealings.values()).size > 1) {
1939
+ const sealed = [...sealings.entries()].filter(([, seals]) => seals).map(([name]) => name);
1940
+ const open = [...sealings.entries()].filter(([, seals]) => !seals).map(([name]) => name);
1941
+ reportDiagnostic(context.program, {
1942
+ code: "unmirrorable-seal",
1943
+ target: NoTarget,
1944
+ format: { sealed: sealed.join(", "), open: open.join(", ") },
1945
+ });
1946
+ }
1875
1947
  for (const snapshot of snapshots) {
1876
1948
  beginOperationIds();
1877
1949
  const service = snapshot.service;
package/dist/src/lib.d.ts CHANGED
@@ -125,6 +125,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
125
125
  "empty-union": {
126
126
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
127
127
  };
128
+ "unmirrorable-seal": {
129
+ readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
130
+ };
128
131
  "unknown-key-vocabulary": {
129
132
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
130
133
  };
@@ -138,7 +141,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
138
141
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
139
142
  };
140
143
  }, EmitterOptions, never>;
141
- export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
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 {
142
145
  "unsupported-type": {
143
146
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
144
147
  };
@@ -151,6 +154,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
151
154
  "empty-union": {
152
155
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
153
156
  };
157
+ "unmirrorable-seal": {
158
+ readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
159
+ };
154
160
  "unknown-key-vocabulary": {
155
161
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
156
162
  };
@@ -176,6 +182,9 @@ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "dup
176
182
  "empty-union": {
177
183
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
178
184
  };
185
+ "unmirrorable-seal": {
186
+ readonly default: import("@typespec/compiler").CallableMessage<["sealed", "open"]>;
187
+ };
179
188
  "unknown-key-vocabulary": {
180
189
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
181
190
  };
package/dist/src/lib.js CHANGED
@@ -127,6 +127,19 @@ const diagnostics = {
127
127
  * consumer can iterate a closed key set at run time; a typo would emit no tuple at all, and "the
128
128
  * vocabulary is missing" looks exactly like "the vocabulary is empty" at the point it is used.
129
129
  */
130
+ /**
131
+ * Two services sealing differently, which `@typespec/openapi3` cannot mirror.
132
+ *
133
+ * An error rather than a warning, on the same footing as every other divergence here: the output
134
+ * is a document and a validator that contradict each other about whether a property may exist,
135
+ * and shipping that is worse than failing the build.
136
+ */
137
+ "unmirrorable-seal": {
138
+ severity: "error",
139
+ messages: {
140
+ default: paramMessage `'seal-object-schemas' resolves to true for ${"sealed"} and false for ${"open"}, and '@typespec/openapi3' has no per-service options - it applies one value to the whole program. So one of these services would publish a document that disagrees with the validator emitted beside it. Give every service the same value, or split the surfaces into separate compiles.`,
141
+ },
142
+ },
130
143
  "unknown-key-vocabulary": {
131
144
  severity: "error",
132
145
  messages: {
package/dist/src/types.js CHANGED
@@ -256,7 +256,23 @@ export function propertyToTs(program, property, wireName) {
256
256
  */
257
257
  if (property.defaultValue !== undefined)
258
258
  return `${key}: ${value};`;
259
- // `exactOptionalPropertyTypes` is on, and Zod's `.optional()` infers `T | undefined`.
259
+ /**
260
+ * **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
261
+ *
262
+ * It looks wrong - JSON has no `undefined`, so a wire type saying `?: T` reads more honest, and
263
+ * it would make this walk agree with `Exact<z.infer<...>>` on the schemas side. **Measured, it
264
+ * breaks the most ordinary handler there is.** `createWidget: (ctx, input) => ok(input)` - return
265
+ * what you were given - stopped compiling, because what a handler RECEIVES carries
266
+ * `?: T | undefined` and what it would then have to SUPPLY did not.
267
+ *
268
+ * That is the difference from the index signature and from `readonly`, which look like the same
269
+ * class and are not: removing those two makes MORE values assignable, and removing this one makes
270
+ * fewer. `{ retiredOn: undefined }` serialises to `{}`, identical to omitting the key, so
271
+ * permitting it costs the wire nothing and refusing it costs every producer a conditional spread.
272
+ *
273
+ * The two emitted surfaces differ here on purpose, exactly as they do on openness:
274
+ * `schemas.gen.ts` exports the narrow received view, this walk emits the permissive floor.
275
+ */
260
276
  return property.optional ? `${key}?: ${value} | undefined;` : `${key}: ${value};`;
261
277
  }
262
278
  /** Installed by the registry so a named model is declared once and referenced everywhere else. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",