typespec-http-zod 0.16.0 → 0.18.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
@@ -617,7 +617,13 @@ function alternateSchemaFor(operation, status, registry) {
617
617
  function multipartTsOf(body, registry) {
618
618
  const entries = body.parts.map((part) => {
619
619
  const type = part.body?.type;
620
- const inner = type === undefined || isBinaryPart(type) ? "unknown" : registry.expressionFor(type);
620
+ const inner = type === undefined
621
+ ? "unknown"
622
+ : isFilePart(type)
623
+ ? MULTIPART_FILE
624
+ : isBinaryPart(type)
625
+ ? "unknown"
626
+ : registry.expressionFor(type);
621
627
  const value = part.multi === true ? `(${inner})[]` : inner;
622
628
  const optional = part.optional === true ? "?" : "";
623
629
  return `\t${objectKey(part.name ?? "")}${optional}: ${value};`;
@@ -627,7 +633,13 @@ function multipartTsOf(body, registry) {
627
633
  function multipartSchemaOf(body, registry) {
628
634
  const entries = body.parts.map((part) => {
629
635
  const type = part.body?.type;
630
- const inner = type === undefined || isBinaryPart(type) ? "z.unknown()" : registry.expressionFor(type);
636
+ const inner = type === undefined
637
+ ? "z.unknown()"
638
+ : isFilePart(type)
639
+ ? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
640
+ : isBinaryPart(type)
641
+ ? "z.unknown()"
642
+ : registry.expressionFor(type);
631
643
  // `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
632
644
  const value = part.multi === true ? `z.array(${inner})` : inner;
633
645
  return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.optional()` : value},`;
@@ -642,6 +654,60 @@ function multipartSchemaOf(body, registry) {
642
654
  const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
643
655
  return name === undefined ? shape : registry.declareNamed(name, () => shape);
644
656
  }
657
+ /**
658
+ * The shape a multipart file part actually arrives as, spelled structurally.
659
+ *
660
+ * **Structural rather than `File`, because emitted output must depend on no ambient library.**
661
+ * `File` is reachable only through `lib.dom` or `@types/node`, and this package's own
662
+ * emitted-output typecheck runs with `types: []` precisely so a dependency like that cannot creep
663
+ * in. The three members are the ones a handler needs and the ones {@link MULTIPART_FILE_REFINEMENT}
664
+ * actually verifies - claiming more would be asserting what nothing established.
665
+ *
666
+ * `arrayBuffer` is a property rather than a method on purpose: `Declared<>` maps the inferred side
667
+ * of the wire assertion, and a mapped type turns a method into a function-typed property, so
668
+ * spelling it this way on both sides is what keeps the two identical.
669
+ */
670
+ const MULTIPART_FILE = "{ name: string; type: string; arrayBuffer: () => Promise<ArrayBuffer> }";
671
+ /**
672
+ * The check that makes {@link MULTIPART_FILE} established rather than asserted.
673
+ *
674
+ * **Why this is derivable from the contract, which is the rule `test/vocabulary.test.ts` enforces.**
675
+ * `@typespec/openapi3` publishes a bare `{}` for a `HttpPart<File>`, in 3.1 and even where the part
676
+ * declares a content type. That is OpenAPI's IDIOM for binary content in a multipart body rather
677
+ * than a statement that any value is acceptable, and the transport says the same: Hono types a
678
+ * multipart part as `string | File` and nothing else. So this refuses exactly one thing - a text
679
+ * field where the spec declared a file - which is malformed against the spec that produced the
680
+ * document. A spec meaning "either" writes `HttpPart<File | string>`, so nothing becomes
681
+ * inexpressible; the declaration simply means what it says.
682
+ *
683
+ * **`z.unknown().refine()` rather than `z.custom<T>()`, and the difference is not stylistic.**
684
+ * Measured on zod 4.4.3: `z.toJSONSchema(z.custom(...))` throws `Custom types cannot be represented
685
+ * in JSON Schema`, which silently took four corpus schemas out of the differential comparing these
686
+ * validators against the document. Refined `z.unknown()` serialises to `{}` - exactly what openapi3
687
+ * publishes - so that comparison keeps working. Zod 4 narrows on a type predicate, so the inferred
688
+ * type is the same either way.
689
+ *
690
+ * Every member the type claims is verified, so the narrowing is sound.
691
+ */
692
+ const MULTIPART_FILE_REFINEMENT = `(value): value is ${MULTIPART_FILE} => typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" && "type" in value && typeof value.type === "string" && "arrayBuffer" in value && typeof value.arrayBuffer === "function"`;
693
+ /**
694
+ * A part declared as a `File`, as opposed to raw `bytes`.
695
+ *
696
+ * **Narrower than {@link isBinaryPart} deliberately.** A `File` part is declared to be a file and
697
+ * `c.req.parseBody()` produces one, so the contract supports saying so. `bytes` says only that the
698
+ * payload is binary; a client may legitimately send it as an ordinary form field, which arrives as a
699
+ * string. Widening this to cover `bytes` would refuse payloads that pass today, on an inference the
700
+ * spec never made.
701
+ */
702
+ function isFilePart(type) {
703
+ if (type.kind !== "Model")
704
+ return false;
705
+ for (let model = type; model !== undefined; model = model.baseModel) {
706
+ if (model.name === "File")
707
+ return true;
708
+ }
709
+ return false;
710
+ }
645
711
  /** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
646
712
  function isBinaryPart(type) {
647
713
  if (type.kind === "Scalar") {
@@ -1593,6 +1659,10 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
1593
1659
  * mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
1594
1660
  * receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
1595
1661
  * would reject every view a producer actually returns.
1662
+ *
1663
+ * No branch for functions, deliberately: the wire vocabulary is primitives, objects and arrays, so
1664
+ * a call signature cannot appear here. Mapping one would yield \`{}\`, so the day something does
1665
+ * carry a method - a narrowed multipart file part is the near miss - this needs one.
1596
1666
  */
1597
1667
  type Produced<T> = T extends readonly (infer Element)[]
1598
1668
  ? readonly Produced<Element>[]
@@ -1655,7 +1725,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
1655
1725
  }
1656
1726
  if (paired.length === 0)
1657
1727
  return undefined;
1658
- const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<z.infer<typeof ${pair.identifier}>, Contracts.${pair.name}>>;`);
1728
+ const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.infer<typeof ${pair.identifier}>>, Contracts.${pair.name}>>;`);
1659
1729
  return `${generatedBanner()}
1660
1730
  import type { z } from "zod";
1661
1731
  import type * as Contracts from "${contractsPackage}";
@@ -1672,6 +1742,45 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
1672
1742
  ? true
1673
1743
  : ["the emitted validator and the shared wire type describe different shapes"];
1674
1744
 
1745
+ /**
1746
+ * A shape with its index signatures removed, at every depth.
1747
+ *
1748
+ * **The two sides of this contract differ on exactly one axis, deliberately.** For an open model the
1749
+ * validator's output carries \`[key: string]: unknown\`, because a loose parse really does pass
1750
+ * unknown keys through. The shared wire type does not, because it is a FLOOR - the properties a
1751
+ * producer must supply - and an index signature there is an obligation on the producer rather than a
1752
+ * description of the payload. TypeScript gives an interface no implicit index signature, so carrying
1753
+ * it made the published shape unsatisfiable by most domain types.
1754
+ *
1755
+ * So this comparison is over DECLARED PROPERTIES, the only claim both sides make. **That the
1756
+ * validator is open wherever the document is open has not stopped being checked** - it is asserted
1757
+ * directly by the emitter's own suite now, instead of falling out of this comparison as a side
1758
+ * effect. Every other kind of drift still fails here exactly as before.
1759
+ *
1760
+ * Applied to the INFERRED side only. If the shared type ever grew an index signature of its own that
1761
+ * is a real disagreement, and this still catches it.
1762
+ *
1763
+ * Deep, because openness recurs: a nested open model puts one at every level, which is why a single
1764
+ * spread never repaired the shape it was supposed to repair.
1765
+ */
1766
+ type Declared<T> = T extends (...args: never[]) => unknown
1767
+ ? T
1768
+ : T extends readonly (infer Element)[]
1769
+ ? T extends Element[]
1770
+ ? Declared<Element>[]
1771
+ : readonly Declared<Element>[]
1772
+ : T extends object
1773
+ ? {
1774
+ [K in keyof T as string extends K
1775
+ ? never
1776
+ : number extends K
1777
+ ? never
1778
+ : symbol extends K
1779
+ ? never
1780
+ : K]: Declared<T[K]>;
1781
+ }
1782
+ : T;
1783
+
1675
1784
  /**
1676
1785
  * \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
1677
1786
  * 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.18.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",