typespec-http-zod 0.26.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.
@@ -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,5 +1,15 @@
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
+ /**
4
+ * What a walk returns after reporting a refusal.
5
+ *
6
+ * **Reporting does not unwind, which is the point.** A throw abandoned the compile at the first
7
+ * unrepresentable construct; reporting lets the walk carry on and name every one of them in a single
8
+ * run. The walk still has to return a `string`, and `z.never()` is the honest placeholder: valid
9
+ * Zod, rejects everything, and unreachable in practice because the error diagnostic has already
10
+ * failed the compile that would have written it.
11
+ */
12
+ export declare const UNREPRESENTABLE = "z.never()";
3
13
  export declare function withDeferredAnnotator<T>(annotator: (property: ModelProperty) => string | undefined, run: () => T): T;
4
14
  export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
5
15
  export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
@@ -98,23 +108,6 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
98
108
  * for a real subtype - the same rule that decides whether a base may be sealed.
99
109
  */
100
110
  export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
101
- /**
102
- * **What JSON kind a value of this type arrives as, for a parameter carried in text.**
103
- *
104
- * A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
105
- * to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
106
- * inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
107
- * and fails silently the moment the expression is spelled any other way.
108
- *
109
- * It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
110
- * neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
111
- * that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
112
- * caller got a 400, and no document comparison could see it, because both sides agree the parameter
113
- * is a number - the disagreement is with the wire.
114
- *
115
- * Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
116
- * single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
117
- */
118
111
  export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
119
112
  /**
120
113
  * A name as an object key - quoted when it is not a legal JavaScript identifier.
@@ -150,7 +143,21 @@ export declare function objectKey(name: string): string;
150
143
  * comment indented differently from the member it documents is the tell that a generator wrote it.
151
144
  */
152
145
  export declare function jsDocComment(text: string | undefined, indent: string): string;
146
+ export declare function encodedTypeOf(program: Program, target: ModelProperty | Scalar): Scalar | undefined;
153
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;
154
161
  export declare function withRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
155
162
  /** Called by the registry when it hands out the identifier of a declaration that is still open. */
156
163
  export declare function noteBackEdge(): void;
package/dist/src/zod.js CHANGED
@@ -11,7 +11,7 @@ import { reportDiagnostic } from "./lib.js";
11
11
  * Zod, rejects everything, and unreachable in practice because the error diagnostic has already
12
12
  * failed the compile that would have written it.
13
13
  */
14
- const UNREPRESENTABLE = "z.never()";
14
+ export const UNREPRESENTABLE = "z.never()";
15
15
  /**
16
16
  * **Installed by the registry, which is the only place that knows what a declared type is CALLED.**
17
17
  *
@@ -339,16 +339,36 @@ const SCALARS = {
339
339
  string: "z.string()",
340
340
  boolean: "z.boolean()",
341
341
  bytes: "z.string()",
342
- int8: "z.number().int()",
343
- int16: "z.number().int()",
344
- int32: "z.number().int()",
345
- int64: "z.number().int()",
346
- safeint: "z.number().int()",
347
- uint8: "z.number().int()",
348
- uint16: "z.number().int()",
349
- uint32: "z.number().int()",
350
- uint64: "z.number().int()",
351
- integer: "z.number().int()",
342
+ /**
343
+ * **A declared WIDTH is a claim about the value too, and ten of them used to share one check.**
344
+ *
345
+ * Every integer scalar emitted `z.number().int()`, which is "an integer within the safe range" and
346
+ * nothing more. Measured at the wire on `workerd`, against a document publishing the widths below:
347
+ * a `uint8` accepted `-5` and `100000`, an `int32` accepted `3000000000` and `-3000000000`.
348
+ *
349
+ * The differential oracle cannot see it. JSON Schema 2020-12 treats `format` as an annotation, so
350
+ * Ajv on the published document answers VALID for all of those and agrees with the server while
351
+ * both are wrong. Ajv says so out loud: `unknown format "uint8" ignored`.
352
+ *
353
+ * Zod 4 has the named formats for the two widths that have them, and they emit better JSON Schema
354
+ * than a bare bound would. The rest take explicit bounds.
355
+ *
356
+ * **`int64` and `uint64` keep the safe-integer ceiling, and that is a limit rather than a
357
+ * choice.** `JSON.parse` has already lost a value above 2^53 before any validator runs, so the
358
+ * alternatives are to refuse it or to accept a number that is not the one the caller sent.
359
+ * Refusing is the honest one. `z.int64()` exists but is a `bigint`, which a JSON body cannot
360
+ * carry. What changes here is only that `uint64` stops accepting negatives.
361
+ */
362
+ int8: "z.int().min(-128).max(127)",
363
+ int16: "z.int().min(-32768).max(32767)",
364
+ int32: "z.int32()",
365
+ int64: "z.int()",
366
+ safeint: "z.int()",
367
+ uint8: "z.int().min(0).max(255)",
368
+ uint16: "z.int().min(0).max(65535)",
369
+ uint32: "z.uint32()",
370
+ uint64: "z.int().min(0)",
371
+ integer: "z.int()",
352
372
  float: "z.number()",
353
373
  float32: "z.number()",
354
374
  float64: "z.number()",
@@ -859,8 +879,25 @@ function modelToZod(program, model) {
859
879
  * is a number - the disagreement is with the wire.
860
880
  *
861
881
  * Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
862
- * single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
882
+ * single list of what becomes a number.
883
+ *
884
+ * **It still inspects the emitted expression to decide WHICH kind, and that did drift once more.**
885
+ * The test was `startsWith("z.number()")`, and giving each integer width its declared bounds spelled
886
+ * nine of them `z.int(`, `z.int32(` and `z.uint32(` instead. Every one silently lost its wire
887
+ * decoder: 22 arms went red at once, which is the only reason it was not shipped. The prefixes live
888
+ * in one named constant now.
889
+ *
890
+ * **That claim named a `scalars.test.ts` which did not exist, and nothing graded the list.**
891
+ * Measured: dropping `z.uint32(` from it, and separately `z.int(`, left all 468 arms in this
892
+ * package green, because no fixture had ever declared an integer width as a PARAMETER - the only
893
+ * place the decoder is attached. `test/widths/` now declares all ten as query parameters and sends
894
+ * each as text, so a spelling this list forgets fails here rather than at a consumer.
863
895
  */
896
+ /**
897
+ * Every emitted expression that denotes a JSON number. Named once, because `SCALARS` and this list
898
+ * have to agree and nothing about the two being in the same file makes them.
899
+ */
900
+ const NUMERIC_EXPRESSIONS = ["z.number()", "z.int(", "z.int32(", "z.uint32("];
864
901
  export function wireKindOf(program, type) {
865
902
  if (type.kind === "Scalar") {
866
903
  const encoded = encodedTypeOf(program, type);
@@ -870,7 +907,7 @@ export function wireKindOf(program, type) {
870
907
  const mapped = SCALARS[current.name];
871
908
  if (mapped === undefined)
872
909
  continue;
873
- if (mapped.startsWith("z.number()"))
910
+ if (NUMERIC_EXPRESSIONS.some((prefix) => mapped.startsWith(prefix)))
874
911
  return "number";
875
912
  if (mapped === "z.boolean()")
876
913
  return "boolean";
@@ -1026,7 +1063,7 @@ const PARAMETER_STYLE_ENCODINGS = new Set([
1026
1063
  "ArrayEncoding.commaDelimited",
1027
1064
  "ArrayEncoding.newlineDelimited",
1028
1065
  ]);
1029
- function encodedTypeOf(program, target) {
1066
+ export function encodedTypeOf(program, target) {
1030
1067
  const encoded = getEncode(program, target);
1031
1068
  if (encoded === undefined)
1032
1069
  return undefined;
@@ -1172,6 +1209,24 @@ function renderValue(value) {
1172
1209
  }
1173
1210
  return undefined;
1174
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
+ }
1175
1230
  /** A property's declared default, as a JS literal, or `undefined` when it has none. */
1176
1231
  function defaultOf(program, property) {
1177
1232
  const value = property.defaultValue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "codegen",
@@ -42,19 +42,22 @@
42
42
  "access": "public",
43
43
  "provenance": true
44
44
  },
45
+ "dependencies": {
46
+ "redos-detector": "^6.1.4"
47
+ },
45
48
  "devDependencies": {
46
49
  "@types/node": "^26.5.1",
47
- "@typespec/compiler": "1.16.0",
48
- "@typespec/events": "0.86.0",
49
- "@typespec/http": "1.16.0",
50
- "@typespec/http-specs": "0.1.0-alpha.41",
51
- "@typespec/openapi": "1.16.0",
52
- "@typespec/openapi3": "1.16.0",
53
- "@typespec/rest": "0.86.0",
54
- "@typespec/sse": "0.86.0",
55
- "@typespec/streams": "0.86.0",
56
- "@typespec/versioning": "0.86.0",
57
- "@typespec/xml": "0.86.0",
50
+ "@typespec/compiler": "1.17.0",
51
+ "@typespec/events": "0.87.0",
52
+ "@typespec/http": "1.17.0",
53
+ "@typespec/http-specs": "0.1.0-alpha.44",
54
+ "@typespec/openapi": "1.17.0",
55
+ "@typespec/openapi3": "1.17.0",
56
+ "@typespec/rest": "0.87.0",
57
+ "@typespec/sse": "0.87.0",
58
+ "@typespec/streams": "0.87.0",
59
+ "@typespec/versioning": "0.87.0",
60
+ "@typespec/xml": "0.87.0",
58
61
  "ajv": "^8.20.0",
59
62
  "oxfmt": "^0.67.0",
60
63
  "oxlint": "^1.82.0",
@@ -62,14 +65,14 @@
62
65
  "typespec-http-zod": "link:.",
63
66
  "vitest": "^5.0.0",
64
67
  "yaml": "^2.9.1",
65
- "zod": "^4.5.2"
68
+ "zod": "^4.6.5"
66
69
  },
67
70
  "peerDependencies": {
68
71
  "@typespec/compiler": "^1.15.0",
69
72
  "@typespec/http": "^1.15.0",
70
73
  "@typespec/openapi": "^1.15.0",
71
- "@typespec/streams": "^0.85.0 || ^0.86.0",
72
- "@typespec/versioning": "^0.85.0 || ^0.86.0",
74
+ "@typespec/streams": "^0.85.0 || ^0.86.0 || ^0.87.0",
75
+ "@typespec/versioning": "^0.85.0 || ^0.86.0 || ^0.87.0",
73
76
  "zod": "^4.5.0"
74
77
  },
75
78
  "peerDependenciesMeta": {
@@ -85,7 +88,7 @@
85
88
  },
86
89
  "tspMain": "lib/main.tsp",
87
90
  "scripts": {
88
- "build": "tsc -p tsconfig.build.json",
91
+ "build": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
89
92
  "test": "tsc -p tsconfig.build.json && vitest run",
90
93
  "typecheck": "tsc -p tsconfig.json",
91
94
  "lint": "oxlint src/ test/",