typespec-http-zod 0.27.0 → 0.28.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.
@@ -30,7 +30,7 @@ export declare function applyConstraints(program: Program, expression: string, t
30
30
  *
31
31
  * **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
32
32
  * `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
33
- * the timeout is judged the same way as one it decides against. Measured over a realistic spread of
33
+ * the step budget is judged the same way as one it decides against. Measured over a realistic spread of
34
34
  * 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
35
35
  * overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
36
36
  * ordinary and takes **11 seconds** on a 29-character input.
@@ -1,5 +1,5 @@
1
1
  import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
2
- import { isSafePattern } from "redos-detector";
2
+ import { defaultMaxSteps, isSafePattern } from "redos-detector";
3
3
  import { reportDiagnostic } from "./lib.js";
4
4
  /**
5
5
  * Constraint decorators -> the Zod modifiers that enforce them.
@@ -87,18 +87,21 @@ function encodesAsString(program, target) {
87
87
  return (own ?? scalar)?.type.name === "string";
88
88
  }
89
89
  /**
90
- * How long one pattern may be analysed before the answer is "could not prove it".
90
+ * How much analysis one pattern gets before the answer is "could not prove it": a number of steps.
91
91
  *
92
- * **Bounded, because the analysis is itself exponential in the bad case.** Measured: `^(a|a)+$`
93
- * takes 1.6 seconds to decide, and a build should not stall on a pattern whose whole problem is that
94
- * it does too much work. A pattern that cannot be decided inside this is one this emitter cannot
95
- * certify, which is what the warning says.
92
+ * **Bounded, because the analysis is itself exponential in the bad case, and bounded in STEPS,
93
+ * because a verdict must not depend on the machine.** It was 500 ms of wall-clock time, and on a
94
+ * loaded CI host `^([0-9a-f]{40}|[0-9a-f]{64})$` took 93 to 403 ms and once ran out: the same spec
95
+ * warned on one build and not the next. A step count is the same everywhere. `redos-detector`'s own
96
+ * default; measured, `^(a|a)+$` spends it in 1.7 seconds idle and 6 under load, and every ordinary
97
+ * pattern decides in milliseconds. A pattern that cannot be decided inside it is one this emitter
98
+ * cannot certify, which is what the warning says.
96
99
  */
97
- const ANALYSIS_TIMEOUT_MS = 500;
100
+ const ANALYSIS_STEPS = defaultMaxSteps;
98
101
  /**
99
102
  * Patterns already judged, keyed on the text, which is the only input the verdict depends on.
100
103
  *
101
- * The analysis costs up to {@link ANALYSIS_TIMEOUT_MS} a pattern, and one pattern text commonly
104
+ * The analysis costs up to {@link ANALYSIS_STEPS} steps a pattern, and one pattern text commonly
102
105
  * recurs across declarations - `^\\d{4}-\\d{2}-\\d{2}$` appears on several scalars in more than one
103
106
  * consumer spec - so each distinct text is analysed once per process.
104
107
  */
@@ -162,7 +165,7 @@ function firstReportOf(program, code, target) {
162
165
  *
163
166
  * **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
164
167
  * `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
165
- * the timeout is judged the same way as one it decides against. Measured over a realistic spread of
168
+ * the step budget is judged the same way as one it decides against. Measured over a realistic spread of
166
169
  * 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
167
170
  * overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
168
171
  * ordinary and takes **11 seconds** on a 29-character input.
@@ -173,7 +176,8 @@ export function patternIsProvablySafe(pattern) {
173
176
  try {
174
177
  safe = isSafePattern(pattern, {
175
178
  unicode: unicodeSafe(pattern),
176
- timeout: ANALYSIS_TIMEOUT_MS,
179
+ maxSteps: ANALYSIS_STEPS,
180
+ timeout: Number.POSITIVE_INFINITY,
177
181
  }).safe;
178
182
  }
179
183
  catch {
@@ -134,16 +134,37 @@ const RESERVED_DECLARATION_NAMES = new Set([
134
134
  "with",
135
135
  "yield",
136
136
  ]);
137
+ /**
138
+ * **The names the emitted files declare for THEMSELVES, beside the spec's own declarations.**
139
+ *
140
+ * `requests.gen.ts` declares `Produced`, `Simplify`, `ByteStream` and its three lookups; `schemas.gen.ts`
141
+ * declares `ResponseArm`. A model named after one of them was declared twice: `TS2300` for a type
142
+ * alias, and for `WireInputs` or `WireOutputs` something worse, because two interfaces of one name
143
+ * MERGE - the model's properties silently became members of the lookup. So a spec's model of that
144
+ * name is renamed exactly as a reserved word is.
145
+ *
146
+ * Graded by `test/collisions/`, which reads the names an emitted file declares off the OUTPUT rather
147
+ * than off this set, so a helper added later without a line here fails a test.
148
+ */
149
+ const EMITTED_HELPER_NAMES = new Set([
150
+ "ByteStream",
151
+ "Produced",
152
+ "ResponseArm",
153
+ "Simplify",
154
+ "WireInputs",
155
+ "WireOutputs",
156
+ "WireResults",
157
+ ]);
137
158
  /**
138
159
  * The name a declaration is emitted under.
139
160
  *
140
- * A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration. It is
141
- * applied in ONE place so both walks agree: `wire-contract.gen.ts` pairs a validator against a
142
- * contract type by name, and a mangling applied to one side only would break that assertion rather
143
- * than the file.
161
+ * A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration, or is one
162
+ * the emitted file already declares for itself. It is applied in ONE place so both walks agree:
163
+ * `wire-contract.gen.ts` pairs a validator against a contract type by name, and a mangling applied to
164
+ * one side only would break that assertion rather than the file.
144
165
  */
145
166
  function safeDeclarationName(name) {
146
- return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
167
+ return RESERVED_DECLARATION_NAMES.has(name) || EMITTED_HELPER_NAMES.has(name) ? `${name}_` : name;
147
168
  }
148
169
  /** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
149
170
  function declaredNameOf(program, type) {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * **What a result is made of: one member per declared response and media type, and the codes each
3
+ * one answers with.** One derivation, read by every artefact that states a result.
4
+ *
5
+ * `typespec-hono` types a handler's return as a union of these members, `typespec-http-mcp` types a
6
+ * tool's the same way, and `requests.gen.ts` states the same union for a backend that has no
7
+ * framework to import. All three used to hold their own copy of these rules, and three copies of
8
+ * rules that must agree are three that will not: a status one of them could see and another could
9
+ * not is how `WireOutputs` came to publish one success body for an operation answering two.
10
+ */
11
+ /**
12
+ * A status as the DOCUMENT keys it: an exact code, a `4XX` bucket, or the catch-all.
13
+ *
14
+ * `"default"` is kept as itself rather than mapped to a number. The document means "any status not
15
+ * listed above", and inventing one would be answering a question the contract left open.
16
+ */
17
+ export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
18
+ /**
19
+ * The facts about one declared response that decide how its result is shaped.
20
+ *
21
+ * Structural, so both an emitted response (`EmittedResponse`, whose body is a Zod expression) and a
22
+ * resolved one (whose body is a TypeSpec type) satisfy it; the caller says which ones carry a body.
23
+ */
24
+ export interface ResponseFacts {
25
+ readonly status: StatusKey;
26
+ readonly contentTypes: readonly string[];
27
+ readonly binary: boolean;
28
+ readonly streamed: boolean;
29
+ readonly textual: boolean;
30
+ }
31
+ /** How a member's body is served, which decides both its type and the call that serves it. */
32
+ export type BodyServing = "none" | "stream" | "binary" | "json" | "text" | "unvalidated";
33
+ /**
34
+ * One member of an operation's result: one response, served as one media type.
35
+ *
36
+ * **A status offering several media types is several members**, because the body differs by media
37
+ * type: the Swagger Petstore answers `getPetById` with a `Pet` as `application/json` and with text as
38
+ * `application/xml`. One member with a union body could not say which body goes with which type.
39
+ */
40
+ export interface ResultMember<R extends ResponseFacts> {
41
+ readonly response: R;
42
+ /** The response's position in the list it came from, so a caller can find what it names it by. */
43
+ readonly index: number;
44
+ /** The media type served, or `undefined` where the response has no body. */
45
+ readonly mediaType: string | undefined;
46
+ /**
47
+ * Whether the result names the media type: the status offers more than one, or the document names
48
+ * a range such as `image/*`, which is not a type a response can be sent as.
49
+ */
50
+ readonly choosesMediaType: boolean;
51
+ readonly serving: BodyServing;
52
+ /**
53
+ * **The document declares a schema for this body that nothing checks it against**: a model under a
54
+ * media type that is not JSON, which the handler supplies as text (`"unvalidated"`) or, under a
55
+ * type that is not text, as bytes (`"binary"`). A `bytes` or `Http.File` body declares nothing to
56
+ * check, so it is not this.
57
+ */
58
+ readonly unvalidated: boolean;
59
+ }
60
+ /**
61
+ * **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
62
+ * 8.3.1). Two media types are the same type exactly when their essences are equal, so every
63
+ * comparison of a declared media type is of this. `application/json; charset=utf-8` and
64
+ * `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
65
+ * and the response an unvalidated string.
66
+ */
67
+ export declare function mediaTypeEssenceOf(type: string): string;
68
+ /** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
69
+ export declare function isJsonMediaType(type: string): boolean;
70
+ /**
71
+ * **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
72
+ * section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
73
+ *
74
+ * **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
75
+ * has no reader or serialisation this emitter can derive, so it is handed over as the text that
76
+ * arrived and answered as the text the handler writes. Under any other type, such as
77
+ * `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
78
+ * every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
79
+ * well, and is parsed rather than handed over, so it never reaches this question.
80
+ */
81
+ export declare function isTextualMediaType(type: string): boolean;
82
+ /** A media range such as `image/*`, rather than a type a response can be sent as. */
83
+ export declare function isMediaRange(mediaType: string): boolean;
84
+ /**
85
+ * The type of the `contentType` a result names for a member.
86
+ *
87
+ * **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
88
+ * under it does not compile, and TypeScript still narrows a literal member away from it. Only the
89
+ * full range is `string`.
90
+ */
91
+ export declare function mediaTypeTypeOf(mediaType: string): string;
92
+ /**
93
+ * Every member an operation's responses declare, in the order the responses are listed.
94
+ *
95
+ * `hasBody` says which responses carry a body, because the two kinds of record spell that
96
+ * differently - an emitted one has a schema, a resolved one has a type.
97
+ */
98
+ export declare function resultMembersOf<R extends ResponseFacts>(responses: readonly R[], hasBody: (response: R) => boolean): ResultMember<R>[];
99
+ /**
100
+ * The codes a declared response answers with, written out.
101
+ *
102
+ * - An exact code is itself.
103
+ * - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
104
+ * before its range, as a server does - and, where it carries a body, less the codes that cannot.
105
+ * - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
106
+ * again less the contentless codes where it carries a body. `1xx` is not a result a handler
107
+ * returns.
108
+ *
109
+ * **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
110
+ * narrow a result on a status type that admits everything. Empty where nothing is left, which a
111
+ * caller renders as `never`.
112
+ */
113
+ export declare function statusCodesOf<R extends ResponseFacts>(response: R, responses: readonly R[], hasBody: (response: R) => boolean): readonly number[];
@@ -0,0 +1,134 @@
1
+ import { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
2
+ /**
3
+ * **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
4
+ * 8.3.1). Two media types are the same type exactly when their essences are equal, so every
5
+ * comparison of a declared media type is of this. `application/json; charset=utf-8` and
6
+ * `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
7
+ * and the response an unvalidated string.
8
+ */
9
+ export function mediaTypeEssenceOf(type) {
10
+ return (type.split(";")[0] ?? "").trim().toLowerCase();
11
+ }
12
+ /** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
13
+ export function isJsonMediaType(type) {
14
+ return /^application\/(json|.*\+json)$/.test(mediaTypeEssenceOf(type));
15
+ }
16
+ /**
17
+ * **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
18
+ * section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
19
+ *
20
+ * **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
21
+ * has no reader or serialisation this emitter can derive, so it is handed over as the text that
22
+ * arrived and answered as the text the handler writes. Under any other type, such as
23
+ * `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
24
+ * every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
25
+ * well, and is parsed rather than handed over, so it never reaches this question.
26
+ */
27
+ export function isTextualMediaType(type) {
28
+ const essence = mediaTypeEssenceOf(type);
29
+ return essence.startsWith("text/") || essence === "application/xml" || essence.endsWith("+xml");
30
+ }
31
+ /** A media range such as `image/*`, rather than a type a response can be sent as. */
32
+ export function isMediaRange(mediaType) {
33
+ return mediaTypeEssenceOf(mediaType).endsWith("/*");
34
+ }
35
+ /**
36
+ * The type of the `contentType` a result names for a member.
37
+ *
38
+ * **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
39
+ * under it does not compile, and TypeScript still narrows a literal member away from it. Only the
40
+ * full range is `string`.
41
+ */
42
+ export function mediaTypeTypeOf(mediaType) {
43
+ if (!isMediaRange(mediaType))
44
+ return JSON.stringify(mediaType);
45
+ if (mediaType === "*/*")
46
+ return "string";
47
+ return `\`${mediaType.slice(0, -1)}\${string}\``;
48
+ }
49
+ /**
50
+ * Every member an operation's responses declare, in the order the responses are listed.
51
+ *
52
+ * `hasBody` says which responses carry a body, because the two kinds of record spell that
53
+ * differently - an emitted one has a schema, a resolved one has a type.
54
+ */
55
+ export function resultMembersOf(responses, hasBody) {
56
+ return responses.flatMap((response, index) => {
57
+ if (response.contentTypes.length === 0) {
58
+ return [
59
+ {
60
+ response,
61
+ index,
62
+ mediaType: undefined,
63
+ choosesMediaType: false,
64
+ serving: "none",
65
+ unvalidated: false,
66
+ },
67
+ ];
68
+ }
69
+ const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
70
+ return response.contentTypes.map((mediaType) => {
71
+ const serving = servingOf(response, mediaType, hasBody(response));
72
+ return {
73
+ response,
74
+ index,
75
+ mediaType,
76
+ choosesMediaType,
77
+ serving,
78
+ unvalidated: serving === "unvalidated" || (serving === "binary" && !response.binary),
79
+ };
80
+ });
81
+ });
82
+ }
83
+ function servingOf(response, mediaType, bodied) {
84
+ if (!bodied)
85
+ return "none";
86
+ if (response.streamed)
87
+ return "stream";
88
+ if (response.binary)
89
+ return "binary";
90
+ if (isJsonMediaType(mediaType))
91
+ return "json";
92
+ /**
93
+ * **A string body under a non-JSON media type IS the text**, so it is validated and served as is.
94
+ * Any other body under one - a model as `application/xml` - has no serialisation this emitter can
95
+ * derive, so it has to come from the application and is served unvalidated: as the text it writes
96
+ * under a textual media type, and as bytes under any other (see {@link isTextualMediaType}).
97
+ */
98
+ if (response.textual)
99
+ return "text";
100
+ return isTextualMediaType(mediaType) ? "unvalidated" : "binary";
101
+ }
102
+ /**
103
+ * The codes a declared response answers with, written out.
104
+ *
105
+ * - An exact code is itself.
106
+ * - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
107
+ * before its range, as a server does - and, where it carries a body, less the codes that cannot.
108
+ * - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
109
+ * again less the contentless codes where it carries a body. `1xx` is not a result a handler
110
+ * returns.
111
+ *
112
+ * **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
113
+ * narrow a result on a status type that admits everything. Empty where nothing is left, which a
114
+ * caller renders as `never`.
115
+ */
116
+ export function statusCodesOf(response, responses, hasBody) {
117
+ const { status } = response;
118
+ if (typeof status === "number")
119
+ return [status];
120
+ const exact = new Set(responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
121
+ const contentless = new Set(hasBody(response) ? CONTENTLESS_STATUS_CODES : []);
122
+ const classOf = (key) => STATUS_CODES[Number(key.charAt(0))];
123
+ const candidates = status === "default"
124
+ ? [2, 3, 4, 5].flatMap((digit) => STATUS_CODES[digit])
125
+ : classOf(status);
126
+ const ranged = new Set(status === "default"
127
+ ? responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
128
+ ? classOf(candidate.status)
129
+ : [])
130
+ : []);
131
+ return candidates
132
+ .filter((code) => !exact.has(code) && !ranged.has(code) && !contentless.has(code))
133
+ .toSorted((a, b) => a - b);
134
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * **The status codes a server can answer with, by class.** A `4XX` range or a `default` response
3
+ * means "every code in these classes that nothing more specific claims", and a type for such a
4
+ * result needs those codes spelled out: `number` would admit `999`, and a type that admits a status
5
+ * no server can send is a type no client can narrow on.
6
+ *
7
+ * **One list, owned here, because two server emitters each held a copy.** `typespec-hono` and
8
+ * `typespec-http-mcp` both typed a ranged or `default` result from their own table, and the contract
9
+ * types in `requests.gen.ts` need the same one to state a result a backend can hand straight to
10
+ * either. Three copies of a table that must agree are three tables that will not.
11
+ *
12
+ * **Graded outside this package, against Hono's own `http-status.d.ts`,** by the two emitters that
13
+ * serve through it - an expectation that does not come from the code it grades. A code Hono adds
14
+ * turns their arm red here first.
15
+ */
16
+ export declare const STATUS_CODES: {
17
+ readonly 1: readonly [100, 101, 102, 103];
18
+ readonly 2: readonly [200, 201, 202, 203, 204, 205, 206, 207, 208, 226];
19
+ readonly 3: readonly [300, 301, 302, 303, 304, 305, 306, 307, 308];
20
+ readonly 4: readonly [400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451];
21
+ readonly 5: readonly [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511];
22
+ };
23
+ /** The codes a response cannot carry a body under, per RFC 9110: 101, 204, 205 and 304. */
24
+ export declare const CONTENTLESS_STATUS_CODES: readonly [101, 204, 205, 304];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * **The status codes a server can answer with, by class.** A `4XX` range or a `default` response
3
+ * means "every code in these classes that nothing more specific claims", and a type for such a
4
+ * result needs those codes spelled out: `number` would admit `999`, and a type that admits a status
5
+ * no server can send is a type no client can narrow on.
6
+ *
7
+ * **One list, owned here, because two server emitters each held a copy.** `typespec-hono` and
8
+ * `typespec-http-mcp` both typed a ranged or `default` result from their own table, and the contract
9
+ * types in `requests.gen.ts` need the same one to state a result a backend can hand straight to
10
+ * either. Three copies of a table that must agree are three tables that will not.
11
+ *
12
+ * **Graded outside this package, against Hono's own `http-status.d.ts`,** by the two emitters that
13
+ * serve through it - an expectation that does not come from the code it grades. A code Hono adds
14
+ * turns their arm red here first.
15
+ */
16
+ export const STATUS_CODES = {
17
+ 1: [100, 101, 102, 103],
18
+ 2: [200, 201, 202, 203, 204, 205, 206, 207, 208, 226],
19
+ 3: [300, 301, 302, 303, 304, 305, 306, 307, 308],
20
+ 4: [
21
+ 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,
22
+ 421, 422, 423, 424, 425, 426, 428, 429, 431, 451,
23
+ ],
24
+ 5: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
25
+ };
26
+ /** The codes a response cannot carry a body under, per RFC 9110: 101, 204, 205 and 304. */
27
+ export const CONTENTLESS_STATUS_CODES = [101, 204, 205, 304];
package/dist/src/types.js CHANGED
@@ -1,6 +1,6 @@
1
- import { getDiscriminatedUnion, getEncode, isNeverType, resolveEncodedName, } from "@typespec/compiler";
1
+ import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
2
2
  import { reportDiagnostic } from "./lib.js";
3
- import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
3
+ import { discriminatedSubtypes, effectiveIndexer, emitsDefault, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
4
4
  /**
5
5
  * TypeSpec type -> plain TypeScript type text.
6
6
  *
@@ -12,9 +12,10 @@ import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, isO
12
12
  * importing the other.
13
13
  *
14
14
  * **It must agree with `zod.ts` exactly**, because the emitted assertion compares them. Every rule
15
- * here has a counterpart there: `utcDateTime` is a string on both sides, `?` becomes
16
- * `?: T | undefined` because `exactOptionalPropertyTypes` is on, and a property the document leaves
17
- * out of `required` is optional here whether its absence is spelled `?` or covered by a default.
15
+ * here has a counterpart there: `utcDateTime` is a string on both sides, `?` is `?: T` because
16
+ * `.exactOptional()` refuses an explicit `undefined` and `?: T | undefined` only where `.default()`
17
+ * accepts one, and a property the document leaves out of `required` is optional here whether its
18
+ * absence is spelled `?` or covered by a default.
18
19
  *
19
20
  * **The assertion pairs this walk against `z.input`, the type the validator ACCEPTS**, and not
20
21
  * against `z.infer`, which is what it produces. These types are the floor a producer supplies, and
@@ -72,7 +73,7 @@ function refuse(program, type, why, remedy = "") {
72
73
  }
73
74
  function scalarToTs(program, scalar) {
74
75
  // A scalar declaration can carry the encoding itself - see `scalarToZod`.
75
- const encoded = getEncode(program, scalar)?.type;
76
+ const encoded = encodedTypeOf(program, scalar);
76
77
  if (encoded !== undefined && encoded !== scalar)
77
78
  return scalarToTs(program, encoded);
78
79
  let current = scalar;
@@ -261,9 +262,14 @@ function modelToTs(program, model) {
261
262
  * annotation claims is by construction the type this walk renders for the same property.
262
263
  */
263
264
  export function propertyTypeToTs(program, property) {
264
- // `@encode` changes the wire TYPE, resolved exactly as `zod.ts` resolves it - a property encoded as
265
- // a string on one side and an array on the other fails the emitted assertion, and deserves to.
266
- const encoded = getEncode(program, property)?.type;
265
+ /**
266
+ * **`@encode` changes the wire TYPE, and `encodedTypeOf` is the one rule for when it does.** This
267
+ * read `getEncode(...).type` directly, so it missed the exception the Zod walk makes: an array
268
+ * encoding on a QUERY parameter is a style of flattening, not a type, and the validator splits it
269
+ * back into the declared list. So `WireInputs` said `colors: string` for a pipe-delimited query
270
+ * whose handler is given `string[]` - two walks of one property, one of them knowing a rule.
271
+ */
272
+ const encoded = encodedTypeOf(program, property);
267
273
  return typeToTs(program, encoded ?? property.type);
268
274
  }
269
275
  export function propertyToTs(program, property, wireName) {
@@ -297,21 +303,28 @@ export function propertyToTs(program, property, wireName) {
297
303
  * `.default(...)` for it - so the two walks agree without either one restating the rule.
298
304
  */
299
305
  /**
300
- * **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
306
+ * **`?: T`, and `?: T | undefined` only where the validator emits `.default(...)`. The contract
307
+ * type is the validator's INPUT, `z.input`, at every property.**
308
+ *
309
+ * `.exactOptional()` refuses `{ note: undefined }` and `.default(v)` accepts it, so those are the
310
+ * two answers; {@link emitsDefault} says which one this property got. `wire-contract.gen.ts`
311
+ * compares the two walks with nothing widened, so a disagreement here does not compile.
301
312
  *
302
- * It looks wrong - JSON has no `undefined`, so a wire type saying `?: T` reads more honest, and
303
- * it would make this walk agree with `z.infer<...>` on the schemas side. **Measured, it
304
- * breaks the most ordinary handler there is.** `createWidget: (ctx, input) => ok(input)` - return
305
- * what you were given - stopped compiling, because what a handler RECEIVES carries
306
- * `?: T | undefined` and what it would then have to SUPPLY did not.
313
+ * **This was `?: T | undefined` for every optional, kept deliberately on three reasons that were
314
+ * each measured false** once `0.24.0` moved the validator to `.exactOptional()`:
307
315
  *
308
- * That is the difference from the index signature and from `readonly`, which look like the same
309
- * class and are not: removing those two makes MORE values assignable, and removing this one makes
310
- * fewer. `{ retiredOn: undefined }` serialises to `{}`, identical to omitting the key, so
311
- * permitting it costs the wire nothing and refusing it costs every producer a conditional spread.
316
+ * - the echo handler `(ctx, input) => ok(input)` broke without it, because what a handler
317
+ * received carried `| undefined`. What it receives is `z.infer` of `.exactOptional()` now,
318
+ * `?: T`, and the echo compiles;
319
+ * - `{ note: undefined }` serialises identically to omitting the key. A server checks a body
320
+ * before it serialises it: a value typed by `WireOutputs` reached `servedBody` and answered
321
+ * 500 for a body whose bytes were valid, and across a service binding it is never serialised;
322
+ * - refusing it costs a producer a conditional spread. That spread infers `{ note?: string }` on
323
+ * TypeScript 5.9 and 7.0 and assigns to the exact type, and it is what a producer under
324
+ * `exactOptionalPropertyTypes` writes for the handler's own return type anyway.
312
325
  *
313
- * The two emitted surfaces differ here on purpose, exactly as they do on openness:
314
- * `schemas.gen.ts` exports the narrow received view, this walk emits the permissive floor.
326
+ * Two consumers reported the disagreement independently - one carried a `Loosen<T>` to compare the
327
+ * two surfaces - and openapi-typescript, `hc` and `z.infer` all spell an optional `?: T`.
315
328
  */
316
329
  /**
317
330
  * **Optional AT THIS POSITION, not merely optional in the declaration.** A PATCH body's properties
@@ -319,9 +332,9 @@ export function propertyToTs(program, property, wireName) {
319
332
  * what `propertyToZod` has always consulted. This walk read `property.optional` alone, so an
320
333
  * update contract demanded every field while the validator beside it accepted any subset.
321
334
  */
322
- return property.optional || isOptionalAt(program, property)
323
- ? `${key}?: ${value} | undefined;`
324
- : `${key}: ${value};`;
335
+ if (!(property.optional || isOptionalAt(program, property)))
336
+ return `${key}: ${value};`;
337
+ return emitsDefault(program, property) ? `${key}?: ${value} | undefined;` : `${key}?: ${value};`;
325
338
  }
326
339
  /** Installed by the registry so a named model is declared once and referenced everywhere else. */
327
340
  let resolveRef = () => undefined;
@@ -103,4 +103,4 @@ export declare function explodedQueryCall(spec: {
103
103
  * gathers those keys back under the parameter's name before the query object is validated: a model
104
104
  * takes exactly its declared properties, a record every key no other query parameter claims.
105
105
  */
106
- export declare const EXPLODED_QUERY_SOURCE = "\n/** RFC 6570 form expansion of an exploded record or model, gathered back under its parameter. */\nfunction explodedQuery(\n\traw: unknown,\n\tspec: {\n\t\treadonly name: string;\n\t\treadonly others: readonly string[];\n\t\treadonly keys: readonly string[] | null;\n\t\treadonly numbers: readonly string[];\n\t\treadonly booleans: readonly string[];\n\t\treadonly values: \"number\" | \"boolean\" | \"\";\n\t},\n): unknown {\n\tif (raw === null || typeof raw !== \"object\" || Array.isArray(raw) || spec.name in raw) return raw;\n\tconst gathered: Record<string, unknown> = {};\n\tconst rest: Record<string, unknown> = {};\n\tfor (const [key, value] of Object.entries(raw)) {\n\t\tconst own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);\n\t\tif (!own) {\n\t\t\trest[key] = value;\n\t\t\tcontinue;\n\t\t}\n\t\tconst kind = spec.values !== \"\" ? spec.values : spec.numbers.includes(key) ? \"number\" : spec.booleans.includes(key) ? \"boolean\" : \"\";\n\t\tgathered[key] =\n\t\t\ttypeof value !== \"string\"\n\t\t\t\t? value\n\t\t\t\t: kind === \"number\" && value.trim() !== \"\" && Number.isFinite(Number(value))\n\t\t\t\t\t? Number(value)\n\t\t\t\t\t: kind === \"boolean\" && (value === \"true\" || value === \"false\")\n\t\t\t\t\t\t? value === \"true\"\n\t\t\t\t\t\t: value;\n\t}\n\treturn Object.keys(gathered).length === 0 ? raw : { ...rest, [spec.name]: gathered };\n}\n";
106
+ export declare const EXPLODED_QUERY_SOURCE = "\n/** RFC 6570 form expansion of an exploded record or model, gathered back under its parameter. */\nfunction explodedQuery(\n\traw: unknown,\n\tspec: {\n\t\treadonly name: string;\n\t\treadonly others: readonly string[];\n\t\treadonly keys: readonly string[] | null;\n\t\treadonly numbers: readonly string[];\n\t\treadonly booleans: readonly string[];\n\t\treadonly values: \"number\" | \"boolean\" | \"\";\n\t},\n): unknown {\n\tif (raw === null || typeof raw !== \"object\" || Array.isArray(raw)) return raw;\n\tconst gathered: Record<string, unknown> = {};\n\tconst rest: Record<string, unknown> = {};\n\tfor (const [key, value] of Object.entries(raw)) {\n\t\t// Already gathered only where an OBJECT sits under the name: a query value from the wire is a\n\t\t// string or a list of them, and `{ param: 0 }` expands to `?param=0`.\n\t\tif (key === spec.name && value !== null && typeof value === \"object\" && !Array.isArray(value)) return raw;\n\t\tconst own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);\n\t\tif (!own) {\n\t\t\trest[key] = value;\n\t\t\tcontinue;\n\t\t}\n\t\tconst kind = spec.values !== \"\" ? spec.values : spec.numbers.includes(key) ? \"number\" : spec.booleans.includes(key) ? \"boolean\" : \"\";\n\t\tgathered[key] =\n\t\t\ttypeof value !== \"string\"\n\t\t\t\t? value\n\t\t\t\t: kind === \"number\" && value.trim() !== \"\" && Number.isFinite(Number(value))\n\t\t\t\t\t? Number(value)\n\t\t\t\t\t: kind === \"boolean\" && (value === \"true\" || value === \"false\")\n\t\t\t\t\t\t? value === \"true\"\n\t\t\t\t\t\t: value;\n\t}\n\treturn Object.keys(gathered).length === 0 ? raw : { ...rest, [spec.name]: gathered };\n}\n";
@@ -230,10 +230,13 @@ function explodedQuery(
230
230
  readonly values: "number" | "boolean" | "";
231
231
  },
232
232
  ): unknown {
233
- if (raw === null || typeof raw !== "object" || Array.isArray(raw) || spec.name in raw) return raw;
233
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw;
234
234
  const gathered: Record<string, unknown> = {};
235
235
  const rest: Record<string, unknown> = {};
236
236
  for (const [key, value] of Object.entries(raw)) {
237
+ // Already gathered only where an OBJECT sits under the name: a query value from the wire is a
238
+ // string or a list of them, and \`{ param: 0 }\` expands to \`?param=0\`.
239
+ if (key === spec.name && value !== null && typeof value === "object" && !Array.isArray(value)) return raw;
237
240
  const own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);
238
241
  if (!own) {
239
242
  rest[key] = value;
package/dist/src/zod.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Enum, type Model, type ModelProperty, type Program, type Type } from "@typespec/compiler";
1
+ import { type Enum, type Model, type ModelProperty, type Program, type Scalar, type Type } from "@typespec/compiler";
2
2
  import { Visibility } from "@typespec/http";
3
3
  /**
4
4
  * What a walk returns after reporting a refusal.
@@ -143,7 +143,21 @@ export declare function objectKey(name: string): string;
143
143
  * comment indented differently from the member it documents is the tell that a generator wrote it.
144
144
  */
145
145
  export declare function jsDocComment(text: string | undefined, indent: string): string;
146
+ export declare function encodedTypeOf(program: Program, target: ModelProperty | Scalar): Scalar | undefined;
146
147
  export declare function propertyToZod(program: Program, property: ModelProperty): string;
148
+ /**
149
+ * **Whether `propertyToZod` emits `.default(...)` for this property, which is exactly whether its
150
+ * INPUT admits an explicit `undefined`.**
151
+ *
152
+ * `.exactOptional()` refuses `{ p: undefined }`; `.default(v)` accepts it and fills `v`. The contract
153
+ * types in `requests.gen.ts` state the input, so they have to know which of the two this emitted -
154
+ * and asking this function rather than restating its rule is what keeps the two walks agreeing.
155
+ * A default on a REQUIRED property emits neither (`default-on-required-property`), and one whose
156
+ * value cannot be rendered emits no `.default()` (`unsupported-default`); both fall out here.
157
+ *
158
+ * Pure: it reports nothing, so the type walk can ask it without duplicating a diagnostic.
159
+ */
160
+ export declare function emitsDefault(program: Program, property: ModelProperty): boolean;
147
161
  export declare function withRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
148
162
  /** Called by the registry when it hands out the identifier of a declaration that is still open. */
149
163
  export declare function noteBackEdge(): void;
package/dist/src/zod.js CHANGED
@@ -1063,7 +1063,7 @@ const PARAMETER_STYLE_ENCODINGS = new Set([
1063
1063
  "ArrayEncoding.commaDelimited",
1064
1064
  "ArrayEncoding.newlineDelimited",
1065
1065
  ]);
1066
- function encodedTypeOf(program, target) {
1066
+ export function encodedTypeOf(program, target) {
1067
1067
  const encoded = getEncode(program, target);
1068
1068
  if (encoded === undefined)
1069
1069
  return undefined;
@@ -1209,6 +1209,24 @@ function renderValue(value) {
1209
1209
  }
1210
1210
  return undefined;
1211
1211
  }
1212
+ /**
1213
+ * **Whether `propertyToZod` emits `.default(...)` for this property, which is exactly whether its
1214
+ * INPUT admits an explicit `undefined`.**
1215
+ *
1216
+ * `.exactOptional()` refuses `{ p: undefined }`; `.default(v)` accepts it and fills `v`. The contract
1217
+ * types in `requests.gen.ts` state the input, so they have to know which of the two this emitted -
1218
+ * and asking this function rather than restating its rule is what keeps the two walks agreeing.
1219
+ * A default on a REQUIRED property emits neither (`default-on-required-property`), and one whose
1220
+ * value cannot be rendered emits no `.default()` (`unsupported-default`); both fall out here.
1221
+ *
1222
+ * Pure: it reports nothing, so the type walk can ask it without duplicating a diagnostic.
1223
+ */
1224
+ export function emitsDefault(program, property) {
1225
+ const optional = property.optional || isOptionalAt(program, property);
1226
+ return (optional &&
1227
+ property.defaultValue !== undefined &&
1228
+ renderValue(property.defaultValue) !== undefined);
1229
+ }
1212
1230
  /** A property's declared default, as a JS literal, or `undefined` when it has none. */
1213
1231
  function defaultOf(program, property) {
1214
1232
  const value = property.defaultValue;