typespec-http-zod 0.1.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.
@@ -0,0 +1,38 @@
1
+ import { type Program, type Type } from "@typespec/compiler";
2
+ /**
3
+ * Constraint decorators → the Zod modifiers that enforce them.
4
+ *
5
+ * These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, …), which matters twice
6
+ * over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
7
+ * documents them without any help from us.
8
+ *
9
+ * ⚠️ **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint —
10
+ * it is an arbitrary predicate, which a type language has no way to say and therefore no document
11
+ * can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
12
+ * other one and is gone: `.trim()` is a transform, and transforming a value before validating it
13
+ * makes the runtime accept inputs the published contract rejects. The spec states `@pattern`
14
+ * instead, which openapi3 publishes.
15
+ */
16
+ export declare function applyConstraints(program: Program, expression: string, target: Type): string;
17
+ /**
18
+ * A `@pattern` string becomes a regex literal — **verbatim**.
19
+ *
20
+ * ⚠️ **This used to add anchors, and that was wrong twice over.**
21
+ *
22
+ * The docblock claimed "TypeSpec's `@pattern` is a full-match assertion (it follows JSON Schema)".
23
+ * JSON Schema says the opposite, in as many words: *"Recall: regular expressions are not implicitly
24
+ * anchored"* (2020-12 validation, `pattern`). So the document published one expression and the
25
+ * validator enforced another, and the caller who read the document was the one who got the 400.
26
+ *
27
+ * Worse, the anchoring was not even an anchoring. `^` and `$` bind looser than `|`, so a top-level
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
30
+ * document said valid and we rejected; on `"Alex "` we accepted what the pattern's own stated
31
+ * intent forbids. 153 of the 227 emitted `.regex()` calls came from that one scalar.
32
+ *
33
+ * A pattern that means to match the whole string says so in the spec — `^\S(?:[\s\S]*\S)?$`, which
34
+ * is the standard construct for it. That way the document and the validator carry
35
+ * the identical expression, and the conformance differential can assert it character for character
36
+ * instead of guessing at an equivalence.
37
+ */
38
+ export declare function patternToRegex(pattern: string): string;
@@ -0,0 +1,68 @@
1
+ import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
2
+ /**
3
+ * Constraint decorators → the Zod modifiers that enforce them.
4
+ *
5
+ * These are **first-party** TypeSpec decorators (`@minLength`, `@pattern`, …), which matters twice
6
+ * over: the spec author writes standard TypeSpec rather than a dialect, and `@typespec/openapi3`
7
+ * documents them without any help from us.
8
+ *
9
+ * ⚠️ **`@refine` is the ONE that is still ours, and it is on its way out.** It is not a constraint —
10
+ * it is an arbitrary predicate, which a type language has no way to say and therefore no document
11
+ * can publish, so a validator enforcing it enforces something no caller can read. `@trimmed` was the
12
+ * other one and is gone: `.trim()` is a transform, and transforming a value before validating it
13
+ * makes the runtime accept inputs the published contract rejects. The spec states `@pattern`
14
+ * instead, which openapi3 publishes.
15
+ */
16
+ export function applyConstraints(program, expression, target) {
17
+ let result = expression;
18
+ const minLength = getMinLength(program, target);
19
+ const maxLength = getMaxLength(program, target);
20
+ const minValue = getMinValue(program, target);
21
+ const maxValue = getMaxValue(program, target);
22
+ const minItems = getMinItems(program, target);
23
+ const maxItems = getMaxItems(program, target);
24
+ const pattern = getPattern(program, target);
25
+ // `@minLength`/`@minValue`/`@minItems` all land on Zod's `.min()`; they are distinguished by the
26
+ // type they are legal on, which TypeSpec has already checked by the time we get here.
27
+ const min = minLength ?? minValue ?? minItems;
28
+ const max = maxLength ?? maxValue ?? maxItems;
29
+ if (min !== undefined)
30
+ result += `.min(${min})`;
31
+ if (max !== undefined)
32
+ result += `.max(${max})`;
33
+ // Exclusive bounds are a distinct question from inclusive ones — `z.number().positive()` is
34
+ // `.gt(0)`, and emitting `.min(0)` for it would accept a zero share count or a zero spot rate.
35
+ const minExclusive = getMinValueExclusive(program, target);
36
+ const maxExclusive = getMaxValueExclusive(program, target);
37
+ if (minExclusive !== undefined)
38
+ result += `.gt(${minExclusive})`;
39
+ if (maxExclusive !== undefined)
40
+ result += `.lt(${maxExclusive})`;
41
+ if (pattern !== undefined)
42
+ result += `.regex(${patternToRegex(pattern)})`;
43
+ return result;
44
+ }
45
+ /**
46
+ * A `@pattern` string becomes a regex literal — **verbatim**.
47
+ *
48
+ * ⚠️ **This used to add anchors, and that was wrong twice over.**
49
+ *
50
+ * The docblock claimed "TypeSpec's `@pattern` is a full-match assertion (it follows JSON Schema)".
51
+ * JSON Schema says the opposite, in as many words: *"Recall: regular expressions are not implicitly
52
+ * anchored"* (2020-12 validation, `pattern`). So the document published one expression and the
53
+ * validator enforced another, and the caller who read the document was the one who got the 400.
54
+ *
55
+ * Worse, the anchoring was not even an anchoring. `^` and `$` bind looser than `|`, so a top-level
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
58
+ * document said valid and we rejected; on `"Alex "` we accepted what the pattern's own stated
59
+ * intent forbids. 153 of the 227 emitted `.regex()` calls came from that one scalar.
60
+ *
61
+ * A pattern that means to match the whole string says so in the spec — `^\S(?:[\s\S]*\S)?$`, which
62
+ * is the standard construct for it. That way the document and the validator carry
63
+ * the identical expression, and the conformance differential can assert it character for character
64
+ * instead of guessing at an equivalence.
65
+ */
66
+ export function patternToRegex(pattern) {
67
+ return `/${pattern.replace(/\//g, "\\/")}/`;
68
+ }
@@ -0,0 +1,13 @@
1
+ import type { EmitContext } from "@typespec/compiler";
2
+ /**
3
+ * This library's own emitter entry point.
4
+ *
5
+ * ⚠️ **It may import `./api.js`, `./lib.js` and external packages, and nothing else — asserted, not
6
+ * intended.** An emitter built on this library is written exactly like this one, so "the public API
7
+ * is sufficient to build an emitter on" is proved by construction rather than hoped for. The moment
8
+ * this file needs something `api.ts` does not export, the API is wrong and the fix belongs there.
9
+ *
10
+ * That it is one line is the point, not a shortcut: everything this package does is reachable
11
+ * through a single published call, so a server generator adds its own file and nothing else.
12
+ */
13
+ export declare function $onEmit(context: EmitContext): Promise<void>;
@@ -0,0 +1,15 @@
1
+ import { emitHttpZod } from "./api.js";
2
+ /**
3
+ * This library's own emitter entry point.
4
+ *
5
+ * ⚠️ **It may import `./api.js`, `./lib.js` and external packages, and nothing else — asserted, not
6
+ * intended.** An emitter built on this library is written exactly like this one, so "the public API
7
+ * is sufficient to build an emitter on" is proved by construction rather than hoped for. The moment
8
+ * this file needs something `api.ts` does not export, the API is wrong and the fix belongs there.
9
+ *
10
+ * That it is one line is the point, not a shortcut: everything this package does is reachable
11
+ * through a single published call, so a server generator adds its own file and nothing else.
12
+ */
13
+ export async function $onEmit(context) {
14
+ await emitHttpZod(context);
15
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The package's entry point: the public API, and the emitter written against it.
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
6
+ * same position any consumer's would. Keeping them apart is what lets a test assert that the second
7
+ * reaches only into the first.
8
+ */
9
+ export * from "./api.js";
10
+ export { $onEmit } from "./emitter.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The package's entry point: the public API, and the emitter written against it.
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
6
+ * same position any consumer's would. Keeping them apart is what lets a test assert that the second
7
+ * reaches only into the first.
8
+ */
9
+ export * from "./api.js";
10
+ export { $onEmit } from "./emitter.js";
@@ -0,0 +1,181 @@
1
+ import { type JSONSchemaType } from "@typespec/compiler";
2
+ /**
3
+ * The library definition, kept in its own module so `tsp-index.ts` and the emitter can both reach it
4
+ * without importing each other.
5
+ */
6
+ export interface EmitterOptions {
7
+ /**
8
+ * Where the framework-free contract types go, when they should not sit beside the validators.
9
+ *
10
+ * A second output directory rather than a path relative to `emitter-output-dir`, because the two
11
+ * artefacts often belong to different packages on purpose: the Zod lives beside the server that
12
+ * runs it, and the shared type lives wherever the rest of the codebase can import it **without
13
+ * importing the server**. Resolving one from the other with `../../..` would encode a particular
14
+ * layout in the emitter, which is the sort of thing that breaks silently when a package moves.
15
+ *
16
+ * Omitted, the contract types are simply not emitted — the emitter stays usable in a project that
17
+ * wants validators and nothing else.
18
+ */
19
+ "contracts-output-dir"?: string;
20
+ /**
21
+ * The package specifier the emitted Zod and wire assertions import their shared types from.
22
+ *
23
+ * ⚠️ **There is deliberately no default.** It used to default to one repository's own package
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
26
+ * "the caller's own package", so the absence is the answer: with no package named, an enum is
27
+ * emitted inline and the output depends on nothing.
28
+ */
29
+ "contracts-package"?: string;
30
+ /**
31
+ * Whether a model closed in the spec becomes a validator that REJECTS an undeclared property,
32
+ * rather than one that strips it.
33
+ *
34
+ * ⚠️ **Set this to whatever `@typespec/openapi3`'s `seal-object-schemas` is set to.** The two
35
+ * emitters answer the same question about the same models, and this one cannot read the other's
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
38
+ * gives callers a `400` on a payload the contract permits.
39
+ *
40
+ * Defaults to `false`, which is openapi3's own default, so the two agree until somebody changes
41
+ * one of them. A model that says `...Record<never>` is closed either way; that is the spec
42
+ * stating it outright rather than an emitter option deciding.
43
+ */
44
+ "seal-object-schemas"?: boolean;
45
+ /**
46
+ * Models whose PROPERTY NAMES are also emitted as a runtime tuple.
47
+ *
48
+ * ⚠️ **For a key set that is a contract fact but cannot be a `Record` key type.** TypeSpec has no
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
51
+ * leaves a consumer that needs the set at RUN time with nothing to iterate, because a generated
52
+ * type is not a value. Naming the model here writes the keys beside the types as well.
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
56
+ * the spec that no document carries is what got this package's last four decorators deleted; see
57
+ * `tsp-index.ts`.
58
+ *
59
+ * A name matching no model is reported, not ignored: a vocabulary that is silently absent is
60
+ * indistinguishable from one that is empty.
61
+ */
62
+ "key-vocabularies"?: string[];
63
+ /**
64
+ * The specifier the generated files import their runtime contract from. Defaults to
65
+ * `typespec-http-zod/runtime`.
66
+ *
67
+ * ⚠️ **This is here because the emitted response arms are annotated, and the annotation earns its
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
71
+ * `schema`, because `schema: undefined` is legitimate for a bodyless response. The typo then
72
+ * produces a valid-looking arm that validates nothing, which is the failure this package exists to
73
+ * prevent. `satisfies` reports both, at the declaration, naming the key.
74
+ *
75
+ * An emitter built on this library points the default at its own runtime, which re-exports these
76
+ * names; an application that substitutes its own result or context types points it at its own
77
+ * module and re-declares them. That is also what makes the emitted output loadable from this
78
+ * package's own tests, where the package name does not resolve.
79
+ */
80
+ "runtime-module"?: string;
81
+ /**
82
+ * Per-`@service` overrides, keyed by the service namespace name.
83
+ *
84
+ * Two surfaces in one spec do not have to share a destination — an internal API and a public one
85
+ * typically publish to different packages, and the public one is built against by people who must
86
+ * never receive the internal shapes. Falling back to the top-level values keeps a single-service
87
+ * project configured exactly as if this key did not exist.
88
+ */
89
+ services?: Record<string, {
90
+ "output-dir"?: string;
91
+ "contracts-output-dir"?: string;
92
+ "contracts-package"?: string;
93
+ "seal-object-schemas"?: boolean;
94
+ "key-vocabularies"?: string[];
95
+ "runtime-module"?: string;
96
+ }>;
97
+ }
98
+ /**
99
+ * ⚠️ **Exported, because an emitter built on this library must DERIVE its option schema from this
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
102
+ * sets, the wrapping emitter rejects as unknown, or accepts and never passes on.
103
+ */
104
+ export declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
105
+ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
106
+ "unsupported-scalar": {
107
+ readonly default: import("@typespec/compiler").CallableMessage<["artefact", "name"]>;
108
+ };
109
+ "unsupported-type": {
110
+ readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
111
+ };
112
+ "unsupported-default": {
113
+ readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
114
+ };
115
+ "undeclared-discriminator": {
116
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
117
+ };
118
+ "empty-union": {
119
+ readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
120
+ };
121
+ "circular-model": {
122
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
123
+ };
124
+ "unknown-key-vocabulary": {
125
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
126
+ };
127
+ "unsupported-status-code-range": {
128
+ readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
129
+ };
130
+ }, 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
+ };
135
+ "unsupported-type": {
136
+ readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
137
+ };
138
+ "unsupported-default": {
139
+ readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
140
+ };
141
+ "undeclared-discriminator": {
142
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
143
+ };
144
+ "empty-union": {
145
+ readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
146
+ };
147
+ "circular-model": {
148
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
149
+ };
150
+ "unknown-key-vocabulary": {
151
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
152
+ };
153
+ "unsupported-status-code-range": {
154
+ readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
155
+ };
156
+ }[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
+ "unsupported-type": {
161
+ readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
162
+ };
163
+ "unsupported-default": {
164
+ readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
165
+ };
166
+ "undeclared-discriminator": {
167
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
168
+ };
169
+ "empty-union": {
170
+ readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
171
+ };
172
+ "circular-model": {
173
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
174
+ };
175
+ "unknown-key-vocabulary": {
176
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "service"]>;
177
+ };
178
+ "unsupported-status-code-range": {
179
+ readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
180
+ };
181
+ }, C, M>) => void;
@@ -0,0 +1,172 @@
1
+ import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler";
2
+ /**
3
+ * ⚠️ **Exported, because an emitter built on this library must DERIVE its option schema from this
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
6
+ * sets, the wrapping emitter rejects as unknown, or accepts and never passes on.
7
+ */
8
+ export const EmitterOptionsSchema = {
9
+ type: "object",
10
+ additionalProperties: false,
11
+ properties: {
12
+ "contracts-output-dir": { type: "string", nullable: true },
13
+ "contracts-package": { type: "string", nullable: true },
14
+ "seal-object-schemas": { type: "boolean", nullable: true },
15
+ "key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
16
+ "runtime-module": { type: "string", nullable: true },
17
+ services: {
18
+ type: "object",
19
+ nullable: true,
20
+ additionalProperties: {
21
+ type: "object",
22
+ additionalProperties: false,
23
+ properties: {
24
+ "output-dir": { type: "string", nullable: true },
25
+ "contracts-output-dir": { type: "string", nullable: true },
26
+ "contracts-package": { type: "string", nullable: true },
27
+ "seal-object-schemas": { type: "boolean", nullable: true },
28
+ "key-vocabularies": { type: "array", items: { type: "string" }, nullable: true },
29
+ "runtime-module": { type: "string", nullable: true },
30
+ },
31
+ required: [],
32
+ },
33
+ required: [],
34
+ },
35
+ },
36
+ required: [],
37
+ };
38
+ /**
39
+ * What this emitter refuses, and why each refusal is a **diagnostic** rather than an exception.
40
+ *
41
+ * ⚠️ **A thrown error abandons the compile and names no source.** Every refusal here was once
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
44
+ * spec asked for something the emitter has decided not to represent. Measured across a 65-scenario
45
+ * corpus, four scenarios failed and a deliberate refusal (`never` has no runtime representation), a
46
+ * design limit and a genuine stack overflow were **indistinguishable**: all three arrived as the same
47
+ * "crashed" banner.
48
+ *
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*
51
+ * problem in one compile rather than the first one it meets.
52
+ *
53
+ * **Internal invariant violations stay exceptions**, deliberately: `noteExternalImport` called
54
+ * outside its collector is a bug in this library, has no source span to point at, and must not be
55
+ * suppressible by the person writing the spec.
56
+ */
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
+ },
64
+ "unsupported-type": {
65
+ severity: "error",
66
+ messages: {
67
+ default: paramMessage `Cannot emit ${"artefact"} for ${"kind"}: ${"why"}.`,
68
+ },
69
+ },
70
+ "unsupported-default": {
71
+ severity: "error",
72
+ messages: {
73
+ default: paramMessage `Cannot emit a default value of ${"why"}. Only scalar defaults and empty collections are representable.`,
74
+ },
75
+ },
76
+ /**
77
+ * ⚠️ **A discriminated union whose discriminator the document never publishes.**
78
+ *
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
81
+ * `discriminator` keyword while never adding that property to the variant schema. OpenAPI 3.1
82
+ * forbids exactly this: "the `discriminator` field MUST be a required field". So the published
83
+ * contract cannot require the property, and under `seal-object-schemas` it forbids it outright.
84
+ *
85
+ * ⚠️ **Confirmed upstream, not inferred here:** `microsoft/typespec#7141`, open since 2025-04-27,
86
+ * labelled `[Bug]` / `design:needed` / `emitter:openapi3` and filed by a maintainer against these
87
+ * exact shapes. Nothing here needs raising; the refusal stands until that lands.
88
+ *
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
91
+ * wrong, and it was recorded as a "declared divergence" with a warning and a committed list. That
92
+ * is a custom track with a label on it: runtime behaviour derived from nothing the document
93
+ * states. A validator may only enforce what the contract says.
94
+ *
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
97
+ * with no divergence and no refusal. The refusal lands only on the spelling that cannot produce
98
+ * an honest contract.
99
+ */
100
+ "undeclared-discriminator": {
101
+ severity: "error",
102
+ 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
+ },
105
+ },
106
+ "empty-union": {
107
+ severity: "error",
108
+ messages: {
109
+ default: paramMessage `A union with no representable variants (${"why"}) has nothing to validate against.`,
110
+ },
111
+ },
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`.
122
+ */
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
+ /**
130
+ * A `key-vocabularies` entry naming no model in the service.
131
+ *
132
+ * ⚠️ **Reported rather than ignored, because the failure mode is silence.** The option exists so a
133
+ * consumer can iterate a closed key set at run time; a typo would emit no tuple at all, and "the
134
+ * vocabulary is missing" looks exactly like "the vocabulary is empty" at the point it is used.
135
+ */
136
+ "unknown-key-vocabulary": {
137
+ severity: "error",
138
+ messages: {
139
+ default: paramMessage `'${"name"}' is listed in 'key-vocabularies' but service '${"service"}' declares no model of that name, so no key tuple would be emitted for it. Check the spelling, or remove it from the option.`,
140
+ },
141
+ },
142
+ /**
143
+ * A status RANGE that OpenAPI cannot key.
144
+ *
145
+ * ⚠️ **This replaced `mixed-error-bodies`, which was a different refusal for the same scenario and
146
+ * 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
148
+ * 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.
150
+ *
151
+ * ⚠️ **Refused because `@typespec/openapi3` refuses it**, with the same rule copied from its
152
+ * `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
154
+ * for this spec at all. The same source is then either representable by both emitters or neither,
155
+ * which is the only arrangement under which a differential between them means anything.
156
+ *
157
+ * A range that covers a bucket exactly — `@minValue(400) @maxValue(499)` — is supported and emits
158
+ * a `4XX` arm.
159
+ */
160
+ "unsupported-status-code-range": {
161
+ severity: "error",
162
+ messages: {
163
+ default: paramMessage `Status code range '${"start"}' to '${"end"}' cannot be published: OpenAPI can only key a range as 1XX, 2XX, 3XX, 4XX or 5XX, so no document states what this response carries and no validator may be emitted for it. Cover a whole group (\`@minValue(400) @maxValue(499)\` for 4XX) or declare the codes individually.`,
164
+ },
165
+ },
166
+ };
167
+ export const $lib = createTypeSpecLibrary({
168
+ name: "typespec-http-zod",
169
+ diagnostics,
170
+ emitter: { options: EmitterOptionsSchema },
171
+ });
172
+ export const { reportDiagnostic } = $lib;
@@ -0,0 +1,69 @@
1
+ import type { ModelProperty, Program, Type } from "@typespec/compiler";
2
+ interface Declaration {
3
+ readonly identifier: string;
4
+ readonly typeName: string;
5
+ readonly source: string;
6
+ }
7
+ export declare class SchemaRegistry {
8
+ #private;
9
+ constructor(program: Program);
10
+ /**
11
+ * The Zod expression for a type — a bare identifier once the type has a declaration, otherwise
12
+ * inline source. Declaring is depth-first so a model's dependencies are emitted above it, which is
13
+ * what lets the generated file be plain `const` declarations with no forward references.
14
+ */
15
+ expressionFor(type: Type): string;
16
+ /**
17
+ * The Zod for a single property, constraints and optionality included.
18
+ *
19
+ * Used for path/query parameters, which are `ModelProperty`s that belong to no model — so they
20
+ * need the property-level walk rather than the type-level one, or `@minLength` on a path param
21
+ * would be documented and never enforced.
22
+ */
23
+ expressionForProperty(property: ModelProperty): string;
24
+ /**
25
+ * A declaration for a shape the spec never wrote, under a name the document publishes.
26
+ *
27
+ * `@discriminated` with an envelope has no TypeSpec type for its wrapper — openapi3 synthesises
28
+ * one and publishes it as `PetWithEnvelopeCat`, so there is a component with no `Type` to key on.
29
+ * Keyed by name instead, and pushed in the same dependency-first order as everything else, so the
30
+ * envelope declarations land above the union that references them.
31
+ *
32
+ * `declareNamed` exposes the same seam to the route emitter, which needs it for a **multipart**
33
+ * 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.
35
+ */
36
+ declareNamed(name: string, build: () => string): string;
37
+ /** Every declaration, dependencies first. */
38
+ declarations(): readonly Declaration[];
39
+ }
40
+ export interface TsDeclaration {
41
+ readonly name: string;
42
+ /** `true` when the body is an object literal and can be emitted as an `interface`. */
43
+ readonly isObject: boolean;
44
+ /** `true` for an enum-derived alias — declared but not exported, see below. */
45
+ readonly isVocabulary: boolean;
46
+ readonly source: string;
47
+ }
48
+ /**
49
+ * The same walk as {@link SchemaRegistry}, emitting plain TypeScript instead of Zod.
50
+ *
51
+ * 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
53
+ * hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
54
+ * one artefact is named the same way in the other, so the emitted assertion can pair them by name.
55
+ */
56
+ export declare class TypeRegistry {
57
+ #private;
58
+ constructor(program: Program);
59
+ expressionFor(type: Type): string;
60
+ /**
61
+ * The Zod-free type for a single property — used for path and query parameters, which are
62
+ * `ModelProperty`s belonging to no model. Without routing them through the registry a named model
63
+ * or enum in a parameter position is inlined rather than referenced.
64
+ */
65
+ expressionForProperty(property: ModelProperty): string;
66
+ /** Every declaration, dependencies first. */
67
+ declarations(): readonly TsDeclaration[];
68
+ }
69
+ export {};