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 +65 -2
- package/dist/src/types.js +15 -17
- package/dist/src/zod.js +12 -7
- package/package.json +1 -1
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
|
-
/**
|
|
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}
|
|
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
|
-
*
|
|
211
|
+
* **`0.16.0` added the catchall here and `0.17.0` reverted it. Do not add it again.**
|
|
212
212
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* the
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
-
* **
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* the
|
|
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
|
-
*
|
|
225
|
-
*
|
|
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
|
-
|
|
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.
|
|
787
|
-
*
|
|
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 =
|
|
800
|
-
?
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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.
|
|
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",
|