typespec-http-zod 0.15.1 → 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
@@ -601,6 +601,29 @@ function alternateSchemaFor(operation, status, registry) {
601
601
  * document does not state AND reject what a server actually receives, since `c.req.parseBody()`
602
602
  * hands back a `File`. The type of a value is positional, exactly as its visibility is.
603
603
  */
604
+ /**
605
+ * The TS type for a **multipart** request body, built from the same parts as its validator.
606
+ *
607
+ * **`requestBodyOf` returns a type only for `bodyKind === "single"`, so a multipart body reached the
608
+ * request type not at all.** Measured: `UploadInput` was `{ "Content-Type": "multipart/form-data" }`
609
+ * while the validator beside it was `z.object({ file: ..., alt: ... })` and the document published
610
+ * every part. Two artefacts from one program, contradicting each other.
611
+ *
612
+ * **Built from `body.parts`, not from the parts model.** Walking the model types each part as its
613
+ * `HttpPart<T>` wrapper - `file: {}` - where the validator unwraps to `T`. Mirroring
614
+ * {@link multipartSchemaOf} part for part, including `multi` as an array, `optional`, and a binary
615
+ * part as `unknown`, is what makes the two agree by construction rather than by coincidence.
616
+ */
617
+ function multipartTsOf(body, registry) {
618
+ const entries = body.parts.map((part) => {
619
+ const type = part.body?.type;
620
+ const inner = type === undefined || isBinaryPart(type) ? "unknown" : registry.expressionFor(type);
621
+ const value = part.multi === true ? `(${inner})[]` : inner;
622
+ const optional = part.optional === true ? "?" : "";
623
+ return `\t${objectKey(part.name ?? "")}${optional}: ${value};`;
624
+ });
625
+ return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
626
+ }
604
627
  function multipartSchemaOf(body, registry) {
605
628
  const entries = body.parts.map((part) => {
606
629
  const type = part.body?.type;
@@ -619,7 +642,27 @@ function multipartSchemaOf(body, registry) {
619
642
  const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
620
643
  return name === undefined ? shape : registry.declareNamed(name, () => shape);
621
644
  }
622
- /** `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
+ */
623
666
  function isBinaryPart(type) {
624
667
  if (type.kind === "Scalar") {
625
668
  for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
@@ -660,7 +703,9 @@ export function isRawBinaryMediaType(contentTypes) {
660
703
  /** The declared request body type, if the operation takes one. */
661
704
  function requestBodyOf(operation) {
662
705
  const body = operation.parameters.body;
663
- return body?.bodyKind === "single" ? body.type : undefined;
706
+ if (body?.bodyKind === "single")
707
+ return body.type;
708
+ return undefined;
664
709
  }
665
710
  /**
666
711
  * Parameters are emitted as their **own** schemas rather than folded into the body, because the two
@@ -1421,6 +1466,9 @@ function collectRequestTypes(program, registry, service) {
1421
1466
  if (successStatusOf(operation) === undefined)
1422
1467
  continue;
1423
1468
  const body = requestBodyOf(operation);
1469
+ /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
1470
+ const multipartBody = operation.parameters.body;
1471
+ const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
1424
1472
  /**
1425
1473
  * Headers are part of the input too.
1426
1474
  *
@@ -1466,9 +1514,11 @@ function collectRequestTypes(program, registry, service) {
1466
1514
  operationId: operationIdOf(program, operation.operation),
1467
1515
  bodyRef: rawBody !== undefined
1468
1516
  ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1469
- : body === undefined
1470
- ? undefined
1471
- : registry.expressionFor(body),
1517
+ : multipart !== undefined
1518
+ ? multipartTsOf(multipart, registry)
1519
+ : body === undefined
1520
+ ? undefined
1521
+ : registry.expressionFor(body),
1472
1522
  extraProperties,
1473
1523
  });
1474
1524
  }
@@ -1563,6 +1613,10 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
1563
1613
  * mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
1564
1614
  * receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
1565
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.
1566
1620
  */
1567
1621
  type Produced<T> = T extends readonly (infer Element)[]
1568
1622
  ? readonly Produced<Element>[]
@@ -1625,7 +1679,7 @@ function renderWireAssertions(schemas, types, entries, contractsPackage) {
1625
1679
  }
1626
1680
  if (paired.length === 0)
1627
1681
  return undefined;
1628
- 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}>>;`);
1629
1683
  return `${generatedBanner()}
1630
1684
  import type { z } from "zod";
1631
1685
  import type * as Contracts from "${contractsPackage}";
@@ -1642,6 +1696,45 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
1642
1696
  ? true
1643
1697
  : ["the emitted validator and the shared wire type describe different shapes"];
1644
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
+
1645
1738
  /**
1646
1739
  * \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
1647
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
@@ -207,6 +207,25 @@ function modelToTs(program, model) {
207
207
  * Permissiveness is a statement about the VALIDATOR - what it lets through - not about what a
208
208
  * producer must contain. The contract type is the floor: these fields, at least.
209
209
  */
210
+ /**
211
+ * **`0.16.0` added the catchall here and `0.17.0` reverted it. Do not add it again.**
212
+ *
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.
219
+ *
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/`.
224
+ *
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.
228
+ */
210
229
  return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
211
230
  }
212
231
  /**
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.15.1",
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",