typespec-http-zod 0.1.0 → 0.3.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.
@@ -1,12 +1,12 @@
1
1
  import { type Program, type Type } from "@typespec/compiler";
2
2
  /**
3
- * Constraint decorators → the Zod modifiers that enforce them.
3
+ * Constraint decorators -> the Zod modifiers that enforce them.
4
4
  *
5
- * These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, …), which matters twice
5
+ * These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, ...), which matters twice
6
6
  * over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
7
7
  * documents them without any help from us.
8
8
  *
9
- * ⚠️ **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint —
9
+ * **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint -
10
10
  * it is an arbitrary predicate, which a type language has no way to say and therefore no document
11
11
  * can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
12
12
  * other one and is gone: `.trim()` is a transform, and transforming a value before validating it
@@ -15,9 +15,9 @@ import { type Program, type Type } from "@typespec/compiler";
15
15
  */
16
16
  export declare function applyConstraints(program: Program, expression: string, target: Type): string;
17
17
  /**
18
- * A `@pattern` string becomes a regex literal — **verbatim**.
18
+ * A `@pattern` string becomes a regex literal - **verbatim**.
19
19
  *
20
- * ⚠️ **This used to add anchors, and that was wrong twice over.**
20
+ * **This used to add anchors, and that was wrong twice over.**
21
21
  *
22
22
  * The docblock claimed "TypeSpec's `@pattern` is a full-match assertion (it follows JSON Schema)".
23
23
  * JSON Schema says the opposite, in as many words: *"Recall: regular expressions are not implicitly
@@ -26,11 +26,11 @@ export declare function applyConstraints(program: Program, expression: string, t
26
26
  *
27
27
  * Worse, the anchoring was not even an anchoring. `^` and `$` bind looser than `|`, so a top-level
28
28
  * alternation changed meaning rather than being constrained: the spec's `\S|^$` became `^\S|^$`,
29
- * which is `(^\S)|(^$)` — "starts with a non-space, or is empty". Measured on `" Alex "` the
29
+ * which is `(^\S)|(^$)` - "starts with a non-space, or is empty". Measured on `" Alex "` the
30
30
  * document said valid and we rejected; on `"Alex "` we accepted what the pattern's own stated
31
31
  * intent forbids. 153 of the 227 emitted `.regex()` calls came from that one scalar.
32
32
  *
33
- * A pattern that means to match the whole string says so in the spec — `^\S(?:[\s\S]*\S)?$`, which
33
+ * A pattern that means to match the whole string says so in the spec - `^\S(?:[\s\S]*\S)?$`, which
34
34
  * is the standard construct for it. That way the document and the validator carry
35
35
  * the identical expression, and the conformance differential can assert it character for character
36
36
  * instead of guessing at an equivalence.
@@ -1,12 +1,12 @@
1
1
  import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
2
2
  /**
3
- * Constraint decorators → the Zod modifiers that enforce them.
3
+ * Constraint decorators -> the Zod modifiers that enforce them.
4
4
  *
5
- * These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, …), which matters twice
5
+ * These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, ...), which matters twice
6
6
  * over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
7
7
  * documents them without any help from us.
8
8
  *
9
- * ⚠️ **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint —
9
+ * **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint -
10
10
  * it is an arbitrary predicate, which a type language has no way to say and therefore no document
11
11
  * can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
12
12
  * other one and is gone: `.trim()` is a transform, and transforming a value before validating it
@@ -30,7 +30,7 @@ export function applyConstraints(program, expression, target) {
30
30
  result += `.min(${min})`;
31
31
  if (max !== undefined)
32
32
  result += `.max(${max})`;
33
- // Exclusive bounds are a distinct question from inclusive ones — `z.number().positive()` is
33
+ // Exclusive bounds are a distinct question from inclusive ones - `z.number().positive()` is
34
34
  // `.gt(0)`, and emitting `.min(0)` for it would accept a zero share count or a zero spot rate.
35
35
  const minExclusive = getMinValueExclusive(program, target);
36
36
  const maxExclusive = getMaxValueExclusive(program, target);
@@ -43,9 +43,9 @@ export function applyConstraints(program, expression, target) {
43
43
  return result;
44
44
  }
45
45
  /**
46
- * A `@pattern` string becomes a regex literal — **verbatim**.
46
+ * A `@pattern` string becomes a regex literal - **verbatim**.
47
47
  *
48
- * ⚠️ **This used to add anchors, and that was wrong twice over.**
48
+ * **This used to add anchors, and that was wrong twice over.**
49
49
  *
50
50
  * The docblock claimed "TypeSpec's `@pattern` is a full-match assertion (it follows JSON Schema)".
51
51
  * JSON Schema says the opposite, in as many words: *"Recall: regular expressions are not implicitly
@@ -54,11 +54,11 @@ export function applyConstraints(program, expression, target) {
54
54
  *
55
55
  * Worse, the anchoring was not even an anchoring. `^` and `$` bind looser than `|`, so a top-level
56
56
  * alternation changed meaning rather than being constrained: the spec's `\S|^$` became `^\S|^$`,
57
- * which is `(^\S)|(^$)` — "starts with a non-space, or is empty". Measured on `" Alex "` the
57
+ * which is `(^\S)|(^$)` - "starts with a non-space, or is empty". Measured on `" Alex "` the
58
58
  * document said valid and we rejected; on `"Alex "` we accepted what the pattern's own stated
59
59
  * intent forbids. 153 of the 227 emitted `.regex()` calls came from that one scalar.
60
60
  *
61
- * A pattern that means to match the whole string says so in the spec — `^\S(?:[\s\S]*\S)?$`, which
61
+ * A pattern that means to match the whole string says so in the spec - `^\S(?:[\s\S]*\S)?$`, which
62
62
  * is the standard construct for it. That way the document and the validator carry
63
63
  * the identical expression, and the conformance differential can assert it character for character
64
64
  * instead of guessing at an equivalence.
@@ -2,7 +2,7 @@ import type { EmitContext } from "@typespec/compiler";
2
2
  /**
3
3
  * This library's own emitter entry point.
4
4
  *
5
- * ⚠️ **It may import `./api.js`, `./lib.js` and external packages, and nothing else — asserted, not
5
+ * **It may import `./api.js`, `./lib.js` and external packages, and nothing else - asserted, not
6
6
  * intended.** An emitter built on this library is written exactly like this one, so "the public API
7
7
  * is sufficient to build an emitter on" is proved by construction rather than hoped for. The moment
8
8
  * this file needs something `api.ts` does not export, the API is wrong and the fix belongs there.
@@ -2,7 +2,7 @@ import { emitHttpZod } from "./api.js";
2
2
  /**
3
3
  * This library's own emitter entry point.
4
4
  *
5
- * ⚠️ **It may import `./api.js`, `./lib.js` and external packages, and nothing else — asserted, not
5
+ * **It may import `./api.js`, `./lib.js` and external packages, and nothing else - asserted, not
6
6
  * intended.** An emitter built on this library is written exactly like this one, so "the public API
7
7
  * is sufficient to build an emitter on" is proved by construction rather than hoped for. The moment
8
8
  * this file needs something `api.ts` does not export, the API is wrong and the fix belongs there.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The package's entry point: the public API, and the emitter written against it.
3
3
  *
4
- * Split in two deliberately. `api.ts` is everything a consumer may use — including another emitter,
5
- * which is what `typespec-hono` is — and `emitter.ts` is this package's own `$onEmit`, holding the
4
+ * Split in two deliberately. `api.ts` is everything a consumer may use - including another emitter,
5
+ * which is what `typespec-hono` is - and `emitter.ts` is this package's own `$onEmit`, holding the
6
6
  * same position any consumer's would. Keeping them apart is what lets a test assert that the second
7
7
  * reaches only into the first.
8
8
  */
package/dist/src/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The package's entry point: the public API, and the emitter written against it.
3
3
  *
4
- * Split in two deliberately. `api.ts` is everything a consumer may use — including another emitter,
5
- * which is what `typespec-hono` is — and `emitter.ts` is this package's own `$onEmit`, holding the
4
+ * Split in two deliberately. `api.ts` is everything a consumer may use - including another emitter,
5
+ * which is what `typespec-hono` is - and `emitter.ts` is this package's own `$onEmit`, holding the
6
6
  * same position any consumer's would. Keeping them apart is what lets a test assert that the second
7
7
  * reaches only into the first.
8
8
  */
package/dist/src/lib.d.ts CHANGED
@@ -13,16 +13,16 @@ export interface EmitterOptions {
13
13
  * importing the server**. Resolving one from the other with `../../..` would encode a particular
14
14
  * layout in the emitter, which is the sort of thing that breaks silently when a package moves.
15
15
  *
16
- * Omitted, the contract types are simply not emitted — the emitter stays usable in a project that
16
+ * Omitted, the contract types are simply not emitted - the emitter stays usable in a project that
17
17
  * wants validators and nothing else.
18
18
  */
19
19
  "contracts-output-dir"?: string;
20
20
  /**
21
21
  * The package specifier the emitted Zod and wire assertions import their shared types from.
22
22
  *
23
- * ⚠️ **There is deliberately no default.** It used to default to one repository's own package
23
+ * **There is deliberately no default.** It used to default to one repository's own package
24
24
  * name, so a consumer who configured nothing got validators importing from a package they had
25
- * never heard of — output that looks right and does not resolve. There is no honest default for
25
+ * never heard of - output that looks right and does not resolve. There is no honest default for
26
26
  * "the caller's own package", so the absence is the answer: with no package named, an enum is
27
27
  * emitted inline and the output depends on nothing.
28
28
  */
@@ -31,10 +31,10 @@ export interface EmitterOptions {
31
31
  * Whether a model closed in the spec becomes a validator that REJECTS an undeclared property,
32
32
  * rather than one that strips it.
33
33
  *
34
- * ⚠️ **Set this to whatever `@typespec/openapi3`'s `seal-object-schemas` is set to.** The two
34
+ * **Set this to whatever `@typespec/openapi3`'s `seal-object-schemas` is set to.** The two
35
35
  * emitters answer the same question about the same models, and this one cannot read the other's
36
36
  * configuration. Left mismatched, the published document and the runtime disagree about whether a
37
- * property may exist — and the direction matters: sealing here while the document stays silent
37
+ * property may exist - and the direction matters: sealing here while the document stays silent
38
38
  * gives callers a `400` on a payload the contract permits.
39
39
  *
40
40
  * Defaults to `false`, which is openapi3's own default, so the two agree until somebody changes
@@ -45,14 +45,14 @@ export interface EmitterOptions {
45
45
  /**
46
46
  * Models whose PROPERTY NAMES are also emitted as a runtime tuple.
47
47
  *
48
- * ⚠️ **For a key set that is a contract fact but cannot be a `Record` key type.** TypeSpec has no
48
+ * **For a key set that is a contract fact but cannot be a `Record` key type.** TypeSpec has no
49
49
  * key-constrained `Record` and OpenAPI's `propertyNames` is unreachable from it, so a closed set
50
- * of attribute names is stated by naming the properties — which publishes the constraint, and
50
+ * of attribute names is stated by naming the properties - which publishes the constraint, and
51
51
  * leaves a consumer that needs the set at RUN time with nothing to iterate, because a generated
52
52
  * type is not a value. Naming the model here writes the keys beside the types as well.
53
53
  *
54
- * ⚠️ **An option rather than a decorator, and the distinction is the whole point.** It changes no
55
- * schema, states nothing in the document and enforces nothing — it selects an output. A mark in
54
+ * **An option rather than a decorator, and the distinction is the whole point.** It changes no
55
+ * schema, states nothing in the document and enforces nothing - it selects an output. A mark in
56
56
  * the spec that no document carries is what got this package's last four decorators deleted; see
57
57
  * `tsp-index.ts`.
58
58
  *
@@ -64,10 +64,10 @@ export interface EmitterOptions {
64
64
  * The specifier the generated files import their runtime contract from. Defaults to
65
65
  * `typespec-http-zod/runtime`.
66
66
  *
67
- * ⚠️ **This is here because the emitted response arms are annotated, and the annotation earns its
67
+ * **This is here because the emitted response arms are annotated, and the annotation earns its
68
68
  * keep.** `schemas.gen.ts` declares what each operation may answer with as
69
- * `… satisfies readonly ResponseArm[]`. Measured against the alternative: `as const` needs no
70
- * import at all, and catches a wrong `status` — but it is **blind** to a misspelled or omitted
69
+ * `... satisfies readonly ResponseArm[]`. Measured against the alternative: `as const` needs no
70
+ * import at all, and catches a wrong `status` - but it is **blind** to a misspelled or omitted
71
71
  * `schema`, because `schema: undefined` is legitimate for a bodyless response. The typo then
72
72
  * produces a valid-looking arm that validates nothing, which is the failure this package exists to
73
73
  * prevent. `satisfies` reports both, at the declaration, naming the key.
@@ -81,7 +81,7 @@ export interface EmitterOptions {
81
81
  /**
82
82
  * Per-`@service` overrides, keyed by the service namespace name.
83
83
  *
84
- * Two surfaces in one spec do not have to share a destination — an internal API and a public one
84
+ * Two surfaces in one spec do not have to share a destination - an internal API and a public one
85
85
  * typically publish to different packages, and the public one is built against by people who must
86
86
  * never receive the internal shapes. Falling back to the top-level values keeps a single-service
87
87
  * project configured exactly as if this key did not exist.
@@ -96,16 +96,13 @@ export interface EmitterOptions {
96
96
  }>;
97
97
  }
98
98
  /**
99
- * ⚠️ **Exported, because an emitter built on this library must DERIVE its option schema from this
99
+ * **Exported, because an emitter built on this library must DERIVE its option schema from this
100
100
  * one rather than restate it.** A server emitter adds its own keys and forwards the rest; two schemas
101
- * that have to agree are two lists that will not, and the failure is silent — an option the consumer
101
+ * that have to agree are two lists that will not, and the failure is silent - an option the consumer
102
102
  * sets, the wrapping emitter rejects as unknown, or accepts and never passes on.
103
103
  */
104
104
  export declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
105
105
  export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
106
- "unsupported-scalar": {
107
- readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
108
- };
109
106
  "unsupported-type": {
110
107
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
111
108
  };
@@ -118,9 +115,6 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
118
115
  "empty-union": {
119
116
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
120
117
  };
121
- "circular-model": {
122
- readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
123
- };
124
118
  "unknown-key-vocabulary": {
125
119
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
126
120
  };
@@ -128,10 +122,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
128
122
  readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
129
123
  };
130
124
  }, EmitterOptions, never>;
131
- export declare const reportDiagnostic: <C extends "circular-model" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-scalar" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
132
- "unsupported-scalar": {
133
- readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
134
- };
125
+ export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
135
126
  "unsupported-type": {
136
127
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
137
128
  };
@@ -144,9 +135,6 @@ export declare const reportDiagnostic: <C extends "circular-model" | "empty-unio
144
135
  "empty-union": {
145
136
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
146
137
  };
147
- "circular-model": {
148
- readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
149
- };
150
138
  "unknown-key-vocabulary": {
151
139
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
152
140
  };
@@ -154,9 +142,6 @@ export declare const reportDiagnostic: <C extends "circular-model" | "empty-unio
154
142
  readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
155
143
  };
156
144
  }[C]>(program: import("@typespec/compiler").Program, diag: import("@typespec/compiler").DiagnosticReport<{
157
- "unsupported-scalar": {
158
- readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
159
- };
160
145
  "unsupported-type": {
161
146
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
162
147
  };
@@ -169,9 +154,6 @@ export declare const reportDiagnostic: <C extends "circular-model" | "empty-unio
169
154
  "empty-union": {
170
155
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
171
156
  };
172
- "circular-model": {
173
- readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
174
- };
175
157
  "unknown-key-vocabulary": {
176
158
  readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
177
159
  };
package/dist/src/lib.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler";
2
2
  /**
3
- * ⚠️ **Exported, because an emitter built on this library must DERIVE its option schema from this
3
+ * **Exported, because an emitter built on this library must DERIVE its option schema from this
4
4
  * one rather than restate it.** A server emitter adds its own keys and forwards the rest; two schemas
5
- * that have to agree are two lists that will not, and the failure is silent — an option the consumer
5
+ * that have to agree are two lists that will not, and the failure is silent - an option the consumer
6
6
  * sets, the wrapping emitter rejects as unknown, or accepts and never passes on.
7
7
  */
8
8
  export const EmitterOptionsSchema = {
@@ -38,16 +38,16 @@ export const EmitterOptionsSchema = {
38
38
  /**
39
39
  * What this emitter refuses, and why each refusal is a **diagnostic** rather than an exception.
40
40
  *
41
- * ⚠️ **A thrown error abandons the compile and names no source.** Every refusal here was once
41
+ * **A thrown error abandons the compile and names no source.** Every refusal here was once
42
42
  * `throw new UnsupportedTypeError(...)`, which reaches the user as
43
- * `Emitter "…" crashed! This is a bug. Please contact library author` — wrong on both counts when the
43
+ * `Emitter "..." crashed! This is a bug. Please contact library author` - wrong on both counts when the
44
44
  * spec asked for something the emitter has decided not to represent. Measured across a 65-scenario
45
45
  * corpus, four scenarios failed and a deliberate refusal (`never` has no runtime representation), a
46
46
  * design limit and a genuine stack overflow were **indistinguishable**: all three arrived as the same
47
47
  * "crashed" banner.
48
48
  *
49
49
  * A diagnostic points at the offending declaration, carries a code somebody can search for and
50
- * suppress, and — because reporting does not unwind — lets the walk continue and report *every*
50
+ * suppress, and - because reporting does not unwind - lets the walk continue and report *every*
51
51
  * problem in one compile rather than the first one it meets.
52
52
  *
53
53
  * **Internal invariant violations stay exceptions**, deliberately: `noteExternalImport` called
@@ -55,12 +55,13 @@ export const EmitterOptionsSchema = {
55
55
  * suppressible by the person writing the spec.
56
56
  */
57
57
  const diagnostics = {
58
- "unsupported-scalar": {
59
- severity: "error",
60
- messages: {
61
- default: paramMessage `Cannot emit ${"artefact"} for scalar '${"name"}': this emitter has no rule for it. Give it a known base with 'extends', or encode it with '@encode'.`,
62
- },
63
- },
58
+ /*
59
+ * `unsupported-scalar` was declared here and has been RETIRED. A scalar with no known base is not
60
+ * unrepresentable: `@typespec/openapi3` publishes `scalar Mystery;` as the empty schema `{}`, which
61
+ * asserts nothing and therefore accepts everything. Refusing it emitted `z.never()`, the exact
62
+ * inversion - measured, `"hello"`, `42` and `null` all rejected. Both walks now emit the faithful
63
+ * reading, `z.unknown()` and `unknown`, and openapi3 raises no diagnostic so neither does this.
64
+ */
64
65
  "unsupported-type": {
65
66
  severity: "error",
66
67
  messages: {
@@ -70,37 +71,37 @@ const diagnostics = {
70
71
  "unsupported-default": {
71
72
  severity: "error",
72
73
  messages: {
73
- default: paramMessage `Cannot emit a default value of ${"why"}. Only scalar defaults and empty collections are representable.`,
74
+ default: paramMessage `Cannot emit a default value of ${"why"}. Scalars, arrays and objects are emitted as literals; a default built by a scalar constructor has no literal form. The property keeps its declared shape and loses only the fallback.`,
74
75
  },
75
76
  },
76
77
  /**
77
- * ⚠️ **A discriminated union whose discriminator the document never publishes.**
78
+ * **A discriminated union whose discriminator the document never publishes.**
78
79
  *
79
- * `@discriminated(#{envelope: "none"})` puts the discriminator inside the variant on the wire —
80
- * `{kind: "cat", ...Cat}` — and `@typespec/openapi3` emits `oneOf: [Cat, Dog]` with a
80
+ * `@discriminated(#{envelope: "none"})` puts the discriminator inside the variant on the wire -
81
+ * `{kind: "cat", ...Cat}` - and `@typespec/openapi3` emits `oneOf: [Cat, Dog]` with a
81
82
  * `discriminator` keyword while never adding that property to the variant schema. OpenAPI 3.1
82
83
  * forbids exactly this: "the `discriminator` field MUST be a required field". So the published
83
84
  * contract cannot require the property, and under `seal-object-schemas` it forbids it outright.
84
85
  *
85
- * ⚠️ **Confirmed upstream, not inferred here:** `microsoft/typespec#7141`, open since 2025-04-27,
86
+ * **Confirmed upstream, not inferred here:** `microsoft/typespec#7141`, open since 2025-04-27,
86
87
  * labelled `[Bug]` / `design:needed` / `emitter:openapi3` and filed by a maintainer against these
87
88
  * exact shapes. Nothing here needs raising; the refusal stands until that lands.
88
89
  *
89
- * ⚠️ **Refused rather than compensated for, and that distinction is the whole point.** This
90
- * emitter did briefly inject the discriminator itself — the validator was right, the document was
90
+ * **Refused rather than compensated for, and that distinction is the whole point.** This
91
+ * emitter did briefly inject the discriminator itself - the validator was right, the document was
91
92
  * wrong, and it was recorded as a "declared divergence" with a warning and a committed list. That
92
93
  * is a custom track with a label on it: runtime behaviour derived from nothing the document
93
94
  * states. A validator may only enforce what the contract says.
94
95
  *
95
- * **It is avoidable, and the message says how.** Declaring the discriminator on the variant —
96
- * `model Cat { kind: "cat"; … }` — makes openapi3 publish it as required, and everything works
96
+ * **It is avoidable, and the message says how.** Declaring the discriminator on the variant -
97
+ * `model Cat { kind: "cat"; ... }` - makes openapi3 publish it as required, and everything works
97
98
  * with no divergence and no refusal. The refusal lands only on the spelling that cannot produce
98
99
  * an honest contract.
99
100
  */
100
101
  "undeclared-discriminator": {
101
102
  severity: "error",
102
103
  messages: {
103
- default: paramMessage `'${"name"}' serialises with '${"property"}' inside each variant, but '${"variant"}' does not declare it — so the OpenAPI document omits it, which OpenAPI 3.1 forbids for a discriminator ("the discriminator field MUST be a required field"). Emitting a validator that required it anyway would enforce a rule the published contract does not state. Declare '${"property"}' on '${"variant"}'.`,
104
+ default: paramMessage `'${"name"}' serialises with '${"property"}' inside each variant, but '${"variant"}' does not declare it - so the OpenAPI document omits it, which OpenAPI 3.1 forbids for a discriminator ("the discriminator field MUST be a required field"). Emitting a validator that required it anyway would enforce a rule the published contract does not state. Declare '${"property"}' on '${"variant"}'.`,
104
105
  },
105
106
  },
106
107
  "empty-union": {
@@ -109,27 +110,18 @@ const diagnostics = {
109
110
  default: paramMessage `A union with no representable variants (${"why"}) has nothing to validate against.`,
110
111
  },
111
112
  },
112
- /**
113
- * ⚠️ **Most cycles are not this.** A recursive model is an ordinary construct — `@typespec/openapi3`
114
- * publishes a self-`$ref` and the document is valid — so it is emitted as a reference, deferred
115
- * behind a getter on the property that closes the loop. This diagnostic is for the remainder: a
116
- * cycle whose path holds **no object property**, and so has nowhere to put that getter. A named
117
- * union whose variant refers back to a model still being rendered is the reachable case.
118
- *
119
- * Reported rather than thrown so the walk names every such cycle instead of dying inside the
120
- * first — and, before the getters existed, so an inline cycle produced this rather than a
121
- * `RangeError: Maximum call stack size exceeded`.
113
+ /*
114
+ * `circular-model` was declared here and has been RETIRED, because it refused something that can
115
+ * be represented. It fired on a cycle whose path holds no object property - a union variant or a
116
+ * dictionary value - on the grounds that a getter needs a property to sit on. True, and beside the
117
+ * point: `z.lazy()` needs no property, and deferring the whole declaration is where the laziness
118
+ * goes once nothing smaller can hold it. See `registry.ts` for the measurements, including why the
119
+ * fix needs a structural type and an annotation rather than `z.lazy()` alone.
122
120
  */
123
- "circular-model": {
124
- severity: "error",
125
- messages: {
126
- default: paramMessage `'${"name"}' closes a cycle through a declaration that cannot defer it — a union variant or a dictionary value, neither of which is a property a getter can sit on, so the emitted schema would read itself while initialising. Route the cycle through a model property, or break it in the spec.`,
127
- },
128
- },
129
121
  /**
130
122
  * A `key-vocabularies` entry naming no model in the service.
131
123
  *
132
- * ⚠️ **Reported rather than ignored, because the failure mode is silence.** The option exists so a
124
+ * **Reported rather than ignored, because the failure mode is silence.** The option exists so a
133
125
  * consumer can iterate a closed key set at run time; a typo would emit no tuple at all, and "the
134
126
  * vocabulary is missing" looks exactly like "the vocabulary is empty" at the point it is used.
135
127
  */
@@ -142,19 +134,19 @@ const diagnostics = {
142
134
  /**
143
135
  * A status RANGE that OpenAPI cannot key.
144
136
  *
145
- * ⚠️ **This replaced `mixed-error-bodies`, which was a different refusal for the same scenario and
137
+ * **This replaced `mixed-error-bodies`, which was a different refusal for the same scenario and
146
138
  * the wrong diagnosis of it.** That one said "a service must answer every failure with one body
147
- * shape" — an assumption this emitter no longer makes, since each declared response resolves its
139
+ * shape" - an assumption this emitter no longer makes, since each declared response resolves its
148
140
  * own body. What is genuinely unrepresentable is narrower and is not ours: OpenAPI keys a range as
149
- * `1XX`…`5XX`, so `@minValue(494) @maxValue(499)` has nowhere to go.
141
+ * `1XX`...`5XX`, so `@minValue(494) @maxValue(499)` has nowhere to go.
150
142
  *
151
- * ⚠️ **Refused because `@typespec/openapi3` refuses it**, with the same rule copied from its
143
+ * **Refused because `@typespec/openapi3` refuses it**, with the same rule copied from its
152
144
  * `rangeToOpenAPI`. Bucketing `494-499` into `4XX` anyway would make a server claim a `400`
153
- * carries that body — a rule no document states, because openapi3 declines to publish a document
145
+ * carries that body - a rule no document states, because openapi3 declines to publish a document
154
146
  * for this spec at all. The same source is then either representable by both emitters or neither,
155
147
  * which is the only arrangement under which a differential between them means anything.
156
148
  *
157
- * A range that covers a bucket exactly — `@minValue(400) @maxValue(499)` — is supported and emits
149
+ * A range that covers a bucket exactly - `@minValue(400) @maxValue(499)` - is supported and emits
158
150
  * a `4XX` arm.
159
151
  */
160
152
  "unsupported-status-code-range": {
@@ -1,14 +1,64 @@
1
1
  import type { ModelProperty, Program, Type } from "@typespec/compiler";
2
+ /**
3
+ * Assigns each named TypeSpec model/enum a single Zod declaration, emitted in dependency order.
4
+ *
5
+ * Without this, a model used by twelve operations is inlined twelve times: the generated file grows
6
+ * quadratically, and - worse - the *type* it infers is a fresh structural type at each site, so
7
+ * `apps/web` gets twelve `Company`s that are only accidentally the same. One declaration per model
8
+ * is what makes the emitted types usable as a shared vocabulary rather than as twelve coincidences.
9
+ */
10
+ /**
11
+ * **A cycle with no property on its path - and it used to be refused.**
12
+ *
13
+ * **A recursive model is NOT this.** `model InnerModel { children?: InnerModel[] }` is an ordinary
14
+ * tree - `@typespec/openapi3` publishes a self-`$ref` for it and the document is valid - so it is
15
+ * emitted as a reference, made lazy by a getter on the property that closes the loop (see
16
+ * `modelToZod`). What this handles is a cycle with no property to hang a getter on: a named **union**
17
+ * whose variant refers back to a model still being rendered, or a dictionary VALUE that does.
18
+ *
19
+ * **`circular-model` claimed this was unrepresentable, and it was merely unimplemented.** Measured
20
+ * on Zod 4.4.3, `z.lazy()` closes every one of these at run time - a union-variant cycle and a
21
+ * dictionary-value cycle both parse nested values and, importantly, still **reject at depth**, so the
22
+ * schema has not quietly degraded to something that accepts anything.
23
+ *
24
+ * **But `z.lazy()` alone does not TYPECHECK, and that is the whole difficulty.** Measured with
25
+ * `tsc` under `strict`:
26
+ *
27
+ * ```
28
+ * TS7022: 'branchSchema' implicitly has type 'any' because it does not have a type annotation
29
+ * and is referenced directly or indirectly in its own initializer.
30
+ * TS7022: 'nodeSchema' implicitly has type 'any' ... <- it poisons the sibling too
31
+ * ```
32
+ *
33
+ * Inferring `any` is worse than failing to compile, because `wire-contract.gen.ts` asserts
34
+ * `Identical<z.infer<typeof X>, Contracts.X>` - against `any` that assertion PASSES while proving
35
+ * nothing, which is the one outcome this package treats as worse than a red test.
36
+ *
37
+ * The fix is the recipe Zod documents: give the deferred declaration an explicit `z.ZodType<T>` and
38
+ * declare `T` structurally. Measured, `tsc` exit 0 with the identity assertion still holding.
39
+ *
40
+ * **EVERY member of the cycle needs its structural type, not only the deferred one.** Leaving the
41
+ * others as `export type X = z.infer<typeof xSchema>` reintroduces the loop through the annotation -
42
+ * measured: `TS2456: Type alias 'Branch' circularly references itself` plus `TS2502` and `TS7022`. So
43
+ * cycle membership, not merely deferral, decides which declarations are emitted structurally.
44
+ */
2
45
  interface Declaration {
3
46
  readonly identifier: string;
4
47
  readonly typeName: string;
5
48
  readonly source: string;
49
+ /**
50
+ * The structural TypeScript body for a declaration on a cycle, emitted INSTEAD of the
51
+ * `z.infer<typeof ...>` alias - which cannot be used here without closing the type-level loop.
52
+ */
53
+ readonly structural?: string;
54
+ /** Whether the declaration is annotated `: z.ZodType<typeName>`, which `z.lazy()` requires. */
55
+ readonly annotated?: boolean;
6
56
  }
7
57
  export declare class SchemaRegistry {
8
58
  #private;
9
59
  constructor(program: Program);
10
60
  /**
11
- * The Zod expression for a type — a bare identifier once the type has a declaration, otherwise
61
+ * The Zod expression for a type - a bare identifier once the type has a declaration, otherwise
12
62
  * inline source. Declaring is depth-first so a model's dependencies are emitted above it, which is
13
63
  * what lets the generated file be plain `const` declarations with no forward references.
14
64
  */
@@ -16,7 +66,7 @@ export declare class SchemaRegistry {
16
66
  /**
17
67
  * The Zod for a single property, constraints and optionality included.
18
68
  *
19
- * Used for path/query parameters, which are `ModelProperty`s that belong to no model — so they
69
+ * Used for path/query parameters, which are `ModelProperty`s that belong to no model - so they
20
70
  * need the property-level walk rather than the type-level one, or `@minLength` on a path param
21
71
  * would be documented and never enforced.
22
72
  */
@@ -24,14 +74,14 @@ export declare class SchemaRegistry {
24
74
  /**
25
75
  * A declaration for a shape the spec never wrote, under a name the document publishes.
26
76
  *
27
- * `@discriminated` with an envelope has no TypeSpec type for its wrapper — openapi3 synthesises
77
+ * `@discriminated` with an envelope has no TypeSpec type for its wrapper - openapi3 synthesises
28
78
  * one and publishes it as `PetWithEnvelopeCat`, so there is a component with no `Type` to key on.
29
79
  * Keyed by name instead, and pushed in the same dependency-first order as everything else, so the
30
80
  * envelope declarations land above the union that references them.
31
81
  *
32
82
  * `declareNamed` exposes the same seam to the route emitter, which needs it for a **multipart**
33
83
  * body: the document publishes `MultiPartRequest` as a component, and there is no walkable type
34
- * for it — the parts have to be assembled from `@typespec/http`'s own view of the payload.
84
+ * for it - the parts have to be assembled from `@typespec/http`'s own view of the payload.
35
85
  */
36
86
  declareNamed(name: string, build: () => string): string;
37
87
  /** Every declaration, dependencies first. */
@@ -41,7 +91,7 @@ export interface TsDeclaration {
41
91
  readonly name: string;
42
92
  /** `true` when the body is an object literal and can be emitted as an `interface`. */
43
93
  readonly isObject: boolean;
44
- /** `true` for an enum-derived alias — declared but not exported, see below. */
94
+ /** `true` for an enum-derived alias - declared but not exported, see below. */
45
95
  readonly isVocabulary: boolean;
46
96
  readonly source: string;
47
97
  }
@@ -49,7 +99,7 @@ export interface TsDeclaration {
49
99
  * The same walk as {@link SchemaRegistry}, emitting plain TypeScript instead of Zod.
50
100
  *
51
101
  * Kept as a separate class rather than a mode on the schema registry because the two produce
52
- * different *kinds* of declaration — an `interface` versus a `const` — and their reference resolvers
102
+ * different *kinds* of declaration - an `interface` versus a `const` - and their reference resolvers
53
103
  * hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
54
104
  * one artefact is named the same way in the other, so the emitted assertion can pair them by name.
55
105
  */
@@ -58,11 +108,12 @@ export declare class TypeRegistry {
58
108
  constructor(program: Program);
59
109
  expressionFor(type: Type): string;
60
110
  /**
61
- * The Zod-free type for a single property — used for path and query parameters, which are
111
+ * The Zod-free type for a single property - used for path and query parameters, which are
62
112
  * `ModelProperty`s belonging to no model. Without routing them through the registry a named model
63
113
  * or enum in a parameter position is inlined rather than referenced.
64
114
  */
65
- expressionForProperty(property: ModelProperty): string;
115
+ /** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
116
+ expressionForProperty(property: ModelProperty, wireName?: string): string;
66
117
  /** Every declaration, dependencies first. */
67
118
  declarations(): readonly TsDeclaration[];
68
119
  }