typespec-http-zod 0.16.0 → 0.17.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/dist/src/api.js CHANGED
@@ -642,7 +642,27 @@ function multipartSchemaOf(body, registry) {
642
642
  const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
643
643
  return name === undefined ? shape : registry.declareNamed(name, () => shape);
644
644
  }
645
- /** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
645
+ /**
646
+ * `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema.
647
+ *
648
+ * **A narrowed type for a `File` part was tried in `0.17.0` and reverted; do not try it again
649
+ * without reading this.** The ask was reasonable - `input.file.name` needs a cast today - and every
650
+ * way of granting it breaks something load-bearing:
651
+ *
652
+ * - `z.custom<FileLike>()` throws in `z.toJSONSchema` ("Custom types cannot be represented"),
653
+ * which silently removes those schemas from the differential that compares this emitter's
654
+ * validators against the document. Measured: four corpus schemas dropped out.
655
+ * - `z.unknown().refine(guard)` serialises fine and narrows correctly, but REJECTS a value the
656
+ * published contract permits, and `test/vocabulary.test.ts` refuses it by class for that reason.
657
+ * - Narrowing the type without a check makes the two artefacts disagree and asserts a shape nothing
658
+ * established.
659
+ *
660
+ * The root cause is upstream and visible in this package's own fixtures: `@typespec/openapi3`
661
+ * publishes a bare `{}` for a File part even in 3.1, where `contentMediaType` could express it, and
662
+ * even when the part declares a specific content type. **The day the document says what the part is,
663
+ * the validator may say it too and the type follows for free.** Until then `unknown` is the only
664
+ * honest answer, and a consumer narrows at the boundary.
665
+ */
646
666
  function isBinaryPart(type) {
647
667
  if (type.kind === "Scalar") {
648
668
  for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
@@ -1593,6 +1613,10 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
1593
1613
  * mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
1594
1614
  * receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
1595
1615
  * would reject every view a producer actually returns.
1616
+ *
1617
+ * No branch for functions, deliberately: the wire vocabulary is primitives, objects and arrays, so
1618
+ * a call signature cannot appear here. Mapping one would yield \`{}\`, so the day something does
1619
+ * carry a method - a narrowed multipart file part is the near miss - this needs one.
1596
1620
  */
1597
1621
  type Produced<T> = T extends readonly (infer Element)[]
1598
1622
  ? readonly Produced<Element>[]
@@ -1655,7 +1679,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
1655
1679
  }
1656
1680
  if (paired.length === 0)
1657
1681
  return undefined;
1658
- const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<z.infer<typeof ${pair.identifier}>, Contracts.${pair.name}>>;`);
1682
+ const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.infer<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
1659
1683
  return `${generatedBanner()}
1660
1684
  import type { z } from "zod";
1661
1685
  import type * as Contracts from "${contractsPackage}";
@@ -1672,6 +1696,45 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
1672
1696
  ? true
1673
1697
  : ["the emitted validator and the shared wire type describe different shapes"];
1674
1698
 
1699
+ /**
1700
+ * A shape with its index signatures removed, at every depth.
1701
+ *
1702
+ * **The two sides of this contract differ on exactly one axis, deliberately.** For an open model the
1703
+ * validator's output carries \`[key: string]: unknown\`, because a loose parse really does pass
1704
+ * unknown keys through. The shared wire type does not, because it is a FLOOR - the properties a
1705
+ * producer must supply - and an index signature there is an obligation on the producer rather than a
1706
+ * description of the payload. TypeScript gives an interface no implicit index signature, so carrying
1707
+ * it made the published shape unsatisfiable by most domain types.
1708
+ *
1709
+ * So this comparison is over DECLARED PROPERTIES, the only claim both sides make. **That the
1710
+ * validator is open wherever the document is open has not stopped being checked** - it is asserted
1711
+ * directly by the emitter's own suite now, instead of falling out of this comparison as a side
1712
+ * effect. Every other kind of drift still fails here exactly as before.
1713
+ *
1714
+ * Applied to the INFERRED side only. If the shared type ever grew an index signature of its own that
1715
+ * is a real disagreement, and this still catches it.
1716
+ *
1717
+ * Deep, because openness recurs: a nested open model puts one at every level, which is why a single
1718
+ * spread never repaired the shape it was supposed to repair.
1719
+ */
1720
+ type Declared<T> = T extends (...args: never[]) => unknown
1721
+ ? T
1722
+ : T extends readonly (infer Element)[]
1723
+ ? T extends Element[]
1724
+ ? Declared<Element>[]
1725
+ : readonly Declared<Element>[]
1726
+ : T extends object
1727
+ ? {
1728
+ [K in keyof T as string extends K
1729
+ ? never
1730
+ : number extends K
1731
+ ? never
1732
+ : symbol extends K
1733
+ ? never
1734
+ : K]: Declared<T[K]>;
1735
+ }
1736
+ : T;
1737
+
1675
1738
  /**
1676
1739
  * \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
1677
1740
  * type when the two disagree, and a type alias to an odd type compiles fine \u2014 66 assertions that
package/dist/src/types.js CHANGED
@@ -208,27 +208,25 @@ function modelToTs(program, model) {
208
208
  * producer must contain. The contract type is the floor: these fields, at least.
209
209
  */
210
210
  /**
211
- * **An open model carries its catchall here too, or the two walks disagree.**
211
+ * **`0.16.0` added the catchall here and `0.17.0` reverted it. Do not add it again.**
212
212
  *
213
- * `modelToZod` emits `.loose()` for a `...Record<unknown>` indexer and `.catchall(T)` for a typed
214
- * one, so `z.infer` gains an index signature. This walk emitted the declared properties alone, and
215
- * the emitter's OWN assertion caught it:
216
- * `TS2344: the emitted validator and the shared wire type describe different shapes`. Any spec with
217
- * `...Record<unknown>` on a request model shipped a `wire-contract.gen.ts` that does not compile.
213
+ * The argument for it was that the document says `additionalProperties` and the validator says
214
+ * `.loose()`, so the type should say it too. Both of those state what is TOLERATED ON ARRIVAL. On
215
+ * the type it reads as an obligation on whoever PRODUCES the value, and TypeScript gives an
216
+ * interface no implicit index signature (microsoft/TypeScript#15300) - only a type alias gets one
217
+ * - so whether a consumer could satisfy our published shape came to depend on which keyword their
218
+ * own domain type happened to use, in files this emitter does not own.
218
219
  *
219
- * **Emitted rather than compared away.** The document says `additionalProperties`, the validator
220
- * says `.loose()`, so the type saying it too is what makes all three agree. Relaxing the comparison
221
- * instead would hide the disagreement rather than remove it, and a looser comparison stops catching
222
- * the drift it exists for.
220
+ * Measured on one consumer's document: **58 of 101 components open, 26 of those nesting another
221
+ * open component**, so the obligation cost a structural deep copy of the response tree per
222
+ * response. One spread satisfies one level and does not reach the next, which is the whole shape
223
+ * of the failure - see `test/contractshape/`.
223
224
  *
224
- * The dictionary case above returns before this: a model with an indexer and NO declared properties
225
- * is a `Record<...>` outright rather than an object with a catchall.
225
+ * What arrives is still described honestly, on the surface derived from the validator:
226
+ * `schemas.gen.ts` exports `Exact<z.infer<typeof x>>`, which carries the index signature because
227
+ * a `.loose()` parse really does. Two surfaces, two directions, neither lying.
226
228
  */
227
- const catchall = indexerKey === "string" && indexer !== undefined && !isNeverType(indexer.value)
228
- ? [`\t[key: string]: ${typeToTs(program, indexer.value)};`]
229
- : [];
230
- const all = [...entries, ...catchall];
231
- return all.length === 0 ? "{}" : `{\n${all.join("\n")}\n}`;
229
+ return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
232
230
  }
233
231
  /**
234
232
  * A property line, including its optionality marker.
package/dist/src/zod.js CHANGED
@@ -783,8 +783,13 @@ function modelToZod(program, model) {
783
783
  * Measured on Zod 4.4.3: `.strict()`, `.loose()` and `.catchall()` all read `shape` eagerly, so
784
784
  * each one fires the getter during module initialisation and throws `Cannot access 'X' before
785
785
  * initialization` - the very thing the getter exists to avoid. `z.strictObject` and
786
- * `z.looseObject` take the same shape without reading it. The suffix form stays everywhere else,
787
- * so a model that does not recurse emits exactly the text it emitted before.
786
+ * `z.looseObject` take the same shape without reading it.
787
+ *
788
+ * **So the constructor form is used wherever it exists, not only where a cycle forces it.** It is
789
+ * also what Zod 4 asks for: its own types say "Consider `z.looseObject(A.shape)` instead" on
790
+ * `.loose()` and `.strict()`, and `.passthrough()` is deprecated outright. Emitting one spelling
791
+ * means a model that gains a back edge later does not silently change form, and the eager-`shape`
792
+ * hazard above stops being something this walk has to keep reasoning about.
788
793
  *
789
794
  * **A TYPED catchall has no constructor form, and that used to be a refusal.** There is nowhere
790
795
  * safe to put the getter - `.catchall()` reads `shape` eagerly - so the cycle was reported as
@@ -796,11 +801,11 @@ function modelToZod(program, model) {
796
801
  const lazily = members.some((member) => member.deferred);
797
802
  const constructible = suffix === "" || suffix === ".loose()";
798
803
  const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
799
- const body = lazily
800
- ? constructible
801
- ? `${suffix === ".loose()" ? "z.looseObject" : strict === ".strict()" ? "z.strictObject" : "z.object"}(${shape})`
802
- : `z.lazy(() => z.object(${shape})${suffix}${strict})`
803
- : `z.object(${shape})${suffix}${strict}`;
804
+ const body = constructible
805
+ ? `${suffix === ".loose()" ? "z.looseObject" : strict === ".strict()" ? "z.strictObject" : "z.object"}(${shape})`
806
+ : lazily
807
+ ? `z.lazy(() => z.object(${shape})${suffix}${strict})`
808
+ : `z.object(${shape})${suffix}${strict}`;
804
809
  return applyConstraints(program, body, model);
805
810
  }
806
811
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",