typespec-http-zod 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/api.d.ts CHANGED
@@ -257,6 +257,19 @@ export interface EmittedRoute {
257
257
  * a scalar. Empty when there is none, never `undefined`.
258
258
  */
259
259
  readonly decodedPathParameters: readonly string[];
260
+ /**
261
+ * Every pair of inputs a handler would be handed under ONE key - `@header("x")` beside
262
+ * `@query("x")`, two `@query("x")`, a `@path id` beside a `@header("id")`, a parameter named as
263
+ * the body is. Empty for almost every operation.
264
+ *
265
+ * **A server emitter spreads the parameter groups into one input, so the later group's value
266
+ * silently replaced the earlier one**, after each had been validated against its own schema.
267
+ * `@typespec/http` refuses only a path and a query parameter of one name. The handler input has
268
+ * one value per key and cannot carry both, so a server should not mount such an operation; this
269
+ * package reports it as `shared-input-key`, and states the key as both declarations at once. See
270
+ * {@link SharedInputKey}.
271
+ */
272
+ readonly sharedInputKeys: readonly SharedInputKey[];
260
273
  /**
261
274
  * A query string written into the route itself, as the pairs it spells:
262
275
  * `/items?fixed=true{&param}` carries `[["fixed", "true"]]`. It identifies the route as much as
@@ -439,6 +452,17 @@ export interface EmittedRoute {
439
452
  */
440
453
  readonly security: readonly SecurityRequirement[];
441
454
  }
455
+ /** Where a value reaches a handler's input from. */
456
+ export type InputLocation = "path" | "query" | "header" | "cookie" | "body";
457
+ /** Two inputs a handler would be handed under one key. See {@link EmittedRoute.sharedInputKeys}. */
458
+ export interface SharedInputKey {
459
+ /** The key both arrive under, as the input spells it. */
460
+ readonly key: string;
461
+ /** Where the input declared first comes from. */
462
+ readonly first: InputLocation;
463
+ /** Where the input declared second comes from. */
464
+ readonly second: InputLocation;
465
+ }
442
466
  export declare function collectRoutes(program: Program, registry: SchemaRegistry, service: HttpService): EmittedRoute[];
443
467
  /** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
444
468
  export declare function referencedIn(source: string, name: string): boolean;
package/dist/src/api.js CHANGED
@@ -11,7 +11,7 @@ import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
11
11
  import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
12
12
  export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
13
13
  export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
14
- import { collectExternalImports, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
14
+ import { collectExternalImports, encodedTypeOf, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
15
15
  /**
16
16
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
17
17
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
@@ -328,19 +328,20 @@ function resolveResponses(program, operation) {
328
328
  file: false,
329
329
  };
330
330
  for (const content of response.responses) {
331
- if (entry.body === undefined && content.body?.bodyKind === "single") {
332
- entry.body = bodyTypeOf(content.body.type);
331
+ const body = presentBody(content.body);
332
+ if (entry.body === undefined && body?.bodyKind === "single") {
333
+ entry.body = body.type;
333
334
  }
334
335
  /**
335
336
  * **An `Http.File` response's body is its contents, served as bytes.** The same reading
336
337
  * as a file request body: a media type and nothing to serialise. Read only for
337
338
  * `"single"`, every download in `type/file` declared `image/png` and was served empty.
338
339
  */
339
- if (entry.body === undefined && content.body?.bodyKind === "file") {
340
- entry.body = content.body.contents.type;
340
+ if (entry.body === undefined && body?.bodyKind === "file") {
341
+ entry.body = body.contents.type;
341
342
  entry.file = true;
342
343
  }
343
- for (const contentType of content.body?.contentTypes ?? []) {
344
+ for (const contentType of body?.contentTypes ?? []) {
344
345
  entry.contentTypes.add(contentType);
345
346
  }
346
347
  for (const [name, property] of Object.entries(content.headers ?? {})) {
@@ -606,6 +607,26 @@ export function errorBodyOf(program, service) {
606
607
  function bodyTypeOf(type) {
607
608
  return isVoidType(type) || isNeverType(type) ? undefined : type;
608
609
  }
610
+ /**
611
+ * A declared body as the wire carries it: `undefined` where it is `void` or `never`, so no media
612
+ * type is listed for it either.
613
+ *
614
+ * **`@typespec/http` keeps a body it was told is empty**, media types and all, whenever the response
615
+ * or request declares it explicitly - `@statusCode _: 409; @body body: void`. Every artefact read
616
+ * the media types off it, so a 409 with no body was published as `contentTypes:
617
+ * ["application/json"]` and `schema: undefined`, which a consumer reads as a body it cannot validate,
618
+ * and a `void` request body reached the Zod walk and refused the compile. openapi3 publishes no
619
+ * `content` for `void`. For `never` it publishes `schema: {}` - any value - because its body paths
620
+ * test only for `void`, while it omits a `never` PROPERTY from every schema; a type with no values is
621
+ * no body here, and `reference.md` records the divergence.
622
+ */
623
+ function presentBody(body) {
624
+ return body?.bodyKind === "single" && bodyTypeOf(body.type) === undefined ? undefined : body;
625
+ }
626
+ /** The operation's request body, absent where it is declared `void` or `never`. */
627
+ function requestBodyParameterOf(operation) {
628
+ return presentBody(operation.parameters.body);
629
+ }
609
630
  /**
610
631
  * The media types this operation's success response can be served as.
611
632
  *
@@ -623,15 +644,24 @@ function responseContentTypesOf(operation, status) {
623
644
  if (response.statusCodes !== status)
624
645
  continue;
625
646
  for (const content of response.responses) {
626
- for (const contentType of content.body?.contentTypes ?? [])
647
+ for (const contentType of presentBody(content.body)?.contentTypes ?? []) {
627
648
  types.add(contentType);
649
+ }
628
650
  }
629
651
  }
630
652
  return [...types];
631
653
  }
632
- /** The type a parameter actually crosses the wire as, after any `@encode` on the property. */
654
+ /**
655
+ * The type a parameter actually crosses the wire as, after any `@encode` on the property.
656
+ *
657
+ * **Read through `encodedTypeOf`, the rule the parameter's schema is built by.** This read
658
+ * `getEncode(...).type` directly, which for an array encoding on a query parameter is `string`: the
659
+ * schema kept the declared `int32[]`, as the document does, while the element decoder found no list
660
+ * and left `"1"` to meet `z.int32()`. Every path here that decides how a value is split, decoded or
661
+ * expanded reads this type, so none of them can disagree with the schema it feeds.
662
+ */
633
663
  function encodedParameterType(program, property) {
634
- return getEncode(program, property)?.type ?? property.type;
664
+ return encodedTypeOf(program, property) ?? property.type;
635
665
  }
636
666
  /**
637
667
  * The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
@@ -856,7 +886,7 @@ function isParseableMediaType(type) {
856
886
  }
857
887
  /** The declared request body type, if the operation takes one. */
858
888
  function requestBodyOf(operation) {
859
- const body = operation.parameters.body;
889
+ const body = requestBodyParameterOf(operation);
860
890
  if (body?.bodyKind === "single")
861
891
  return body.type;
862
892
  return undefined;
@@ -893,9 +923,17 @@ const COLLECTION_DELIMITERS = {
893
923
  "ArrayEncoding.commaDelimited": ",",
894
924
  "ArrayEncoding.newlineDelimited": "\n",
895
925
  };
896
- /** Whether the declared type is a list, which is what makes a delimiter meaningful. */
897
- function isCollectionParameter(property) {
898
- return isArrayType(property.type);
926
+ /**
927
+ * Whether the parameter crosses the wire as a list, which is what makes a delimiter meaningful.
928
+ *
929
+ * **The encoded type, not the declared one.** `@header @encode(ArrayEncoding.commaDelimited) tags:
930
+ * string[]` is a string on the wire - the document publishes `type: string` - and its schema is
931
+ * `z.string()`; reading the declared list split `"a,b"` into an array and handed that to the string
932
+ * schema, so every request was refused. Only a query parameter keeps its list under an array
933
+ * encoding, and only there is a split undone.
934
+ */
935
+ function isCollectionParameter(program, property) {
936
+ return isArrayType(encodedParameterType(program, property));
899
937
  }
900
938
  /**
901
939
  * Whether the values arrive one per OCCURRENCE (RFC 6570 explode) rather than joined into one.
@@ -906,8 +944,8 @@ function isCollectionParameter(property) {
906
944
  * refused a conformant request while accepting the same list sent twice, which no document
907
945
  * describes. Found by the first consumer's `?topics=ip` (#1).
908
946
  */
909
- function isExplodedCollection(parameter) {
910
- return (isCollectionParameter(parameter.param) &&
947
+ function isExplodedCollection(program, parameter) {
948
+ return (isCollectionParameter(program, parameter.param) &&
911
949
  (parameter.type === "query" || parameter.type === "header") &&
912
950
  parameter.explode === true);
913
951
  }
@@ -919,7 +957,7 @@ function isExplodedCollection(parameter) {
919
957
  * Those are the two that looked correct because the document and the validator agreed.
920
958
  */
921
959
  function collectionDelimiterOf(program, parameter) {
922
- if (!isCollectionParameter(parameter.param))
960
+ if (!isCollectionParameter(program, parameter.param))
923
961
  return undefined;
924
962
  if (parameter.type !== "query" && parameter.type !== "header")
925
963
  return undefined;
@@ -1078,6 +1116,9 @@ function parameterSchemasOf(program, operation, registry, segments,
1078
1116
  /** The names of a query string written into the route itself, which no parameter may gather. */
1079
1117
  literalQueryNames) {
1080
1118
  const byTarget = {};
1119
+ const add = (target, key, expression) => {
1120
+ (byTarget[target] ??= []).push(`\t${objectKey(key)}: ${expression},`);
1121
+ };
1081
1122
  let accept;
1082
1123
  /** Form-exploded records and models, gathered back under their names around the query object. */
1083
1124
  const gathered = [];
@@ -1115,8 +1156,21 @@ literalQueryNames) {
1115
1156
  * via `resolveEncodedName` - the document publishes one name per parameter, and it is this one.
1116
1157
  * The TypeSpec property name is an authoring convenience that does not survive to the contract.
1117
1158
  */
1159
+ /**
1160
+ * **Two parameters of one location and one wire name are one value on the wire**, and both
1161
+ * declarations apply to it: their schemas are intersected and the value is decoded once, by
1162
+ * the first declaration's wire form, so each judges the same decoded value. Decoded by each
1163
+ * and then intersected, `@query("x") a: string` beside `@query("x") b: int32` would hand Zod
1164
+ * `"1"` and `1` to merge, which it does not refuse but throws on. An object literal cannot
1165
+ * hold the key twice either. `shared-input-key` reports it.
1166
+ */
1167
+ const sharing = operation.parameters.parameters.filter((other) => other.type === parameter.type && other.name === parameter.name);
1168
+ if (sharing[0] !== parameter)
1169
+ continue;
1118
1170
  const delimiter = collectionDelimiterOf(program, parameter);
1119
- const declared = registry.expressionForProperty(parameter.param);
1171
+ const declared = sharing
1172
+ .map((other) => registry.expressionForProperty(other.param))
1173
+ .reduce((first, other) => `${first}.and(${other})`);
1120
1174
  /**
1121
1175
  * The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
1122
1176
  * exactly the flattening the document's `style` describes and then applies the schema the
@@ -1146,7 +1200,7 @@ literalQueryNames) {
1146
1200
  * occurrences arrive already an array and pass through untouched, exactly as the delimiter
1147
1201
  * arm leaves a pre-split value alone.
1148
1202
  */
1149
- const boxedOrSplit = isExplodedCollection(parameter)
1203
+ const boxedOrSplit = isExplodedCollection(program, parameter)
1150
1204
  ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
1151
1205
  : delimiter === undefined
1152
1206
  ? decoded
@@ -1156,11 +1210,12 @@ literalQueryNames) {
1156
1210
  * wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
1157
1211
  * run on that. See {@link uriExpansionCall}.
1158
1212
  */
1159
- const shape = expansionShapeOf(parameter.param.type);
1160
- const values = recordValuesOf(program, parameter.param.type);
1213
+ const wire = encodedParameterType(program, parameter.param);
1214
+ const shape = expansionShapeOf(wire);
1215
+ const values = recordValuesOf(program, wire);
1161
1216
  let expression = boxedOrSplit;
1162
1217
  if (parameter.type === "path") {
1163
- const segment = decodedSegmentOf(parameter, segments);
1218
+ const segment = decodedSegmentOf(program, parameter, segments);
1164
1219
  if (segment !== undefined) {
1165
1220
  expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
1166
1221
  }
@@ -1171,9 +1226,7 @@ literalQueryNames) {
1171
1226
  * `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
1172
1227
  * this entry to read; the gatherer around the query object puts one back.
1173
1228
  */
1174
- const model = parameter.param.type.kind === "Model" && parameter.param.type.indexer === undefined
1175
- ? parameter.param.type
1176
- : undefined;
1229
+ const model = wire.kind === "Model" && wire.indexer === undefined ? wire : undefined;
1177
1230
  const properties = model === undefined ? [] : modelPropertiesOf(model);
1178
1231
  gathered.push(explodedQueryCall({
1179
1232
  name: parameter.name,
@@ -1182,10 +1235,10 @@ literalQueryNames) {
1182
1235
  ? undefined
1183
1236
  : properties.map((property) => resolveEncodedName(program, property, "application/json")),
1184
1237
  numbers: properties
1185
- .filter((property) => wireKindOf(program, property.type) === "number")
1238
+ .filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "number")
1186
1239
  .map((property) => resolveEncodedName(program, property, "application/json")),
1187
1240
  booleans: properties
1188
- .filter((property) => wireKindOf(program, property.type) === "boolean")
1241
+ .filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "boolean")
1189
1242
  .map((property) => resolveEncodedName(program, property, "application/json")),
1190
1243
  values,
1191
1244
  }));
@@ -1194,8 +1247,7 @@ literalQueryNames) {
1194
1247
  expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
1195
1248
  }
1196
1249
  }
1197
- const entry = `\t${objectKey(parameter.name)}: ${expression},`;
1198
- (byTarget[parameter.type] ??= []).push(entry);
1250
+ add(parameter.type, parameter.name, expression);
1199
1251
  /**
1200
1252
  * **On a route several operations share, `accept` SELECTS the operation - it is not a
1201
1253
  * constraint on one of them.** Each member declares its own literal, so validating against any
@@ -1210,15 +1262,15 @@ literalQueryNames) {
1210
1262
  accept = { name: parameter.name, value: declared.value };
1211
1263
  }
1212
1264
  else if (parameter.type === "header") {
1213
- (byTarget["header-without-accept"] ??= []).push(entry);
1265
+ add("header-without-accept", parameter.name, expression);
1214
1266
  }
1215
1267
  }
1216
1268
  const fileContentType = fileContentTypeOf(operation);
1217
1269
  if (fileContentType !== undefined) {
1218
1270
  // The same decoding a declared `Content-Type` header gets: parameters dropped, lowercased.
1219
- const entry = `\t${objectKey("Content-Type")}: ${mediaTypeDecoded(registry.expressionFor(fileContentType.type))},`;
1220
- (byTarget["header"] ??= []).push(entry);
1221
- (byTarget["header-without-accept"] ??= []).push(entry);
1271
+ const expression = mediaTypeDecoded(registry.expressionFor(fileContentType.type));
1272
+ add("header", "Content-Type", expression);
1273
+ add("header-without-accept", "Content-Type", expression);
1222
1274
  }
1223
1275
  const shape = (target) => {
1224
1276
  const entries = byTarget[target];
@@ -1237,20 +1289,25 @@ literalQueryNames) {
1237
1289
  accept,
1238
1290
  };
1239
1291
  }
1240
- /** What a parameter's value decodes into once its expansion is undone. */
1241
1292
  /**
1242
1293
  * The route segment a path parameter's validator decodes, or `undefined` where it validates the
1243
1294
  * segment's text as the value. The one rule for both the validator and
1244
1295
  * {@link EmittedRoute.decodedPathParameters}, so a consumer reading the route cannot disagree with
1245
1296
  * the validator it describes.
1297
+ *
1298
+ * **Shaped from the encoded type, as the validator's schema is.** `@path
1299
+ * @encode(ArrayEncoding.commaDelimited) ids: string[]` is a string on the wire, published as `type:
1300
+ * string`, and validated by `z.string()`; shaped from the declared list it was expanded into
1301
+ * `["a", "b"]` first, and every request was refused.
1246
1302
  */
1247
- function decodedSegmentOf(parameter, segments) {
1303
+ function decodedSegmentOf(program, parameter, segments) {
1248
1304
  const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1249
1305
  return segment !== undefined &&
1250
- needsExpansionDecoding(segment, expansionShapeOf(parameter.param.type))
1306
+ needsExpansionDecoding(segment, expansionShapeOf(encodedParameterType(program, parameter.param)))
1251
1307
  ? segment
1252
1308
  : undefined;
1253
1309
  }
1310
+ /** What a parameter's value decodes into once its expansion is undone. */
1254
1311
  function expansionShapeOf(type) {
1255
1312
  if (isArrayType(type))
1256
1313
  return "list";
@@ -1280,7 +1337,7 @@ function modelPropertiesOf(model) {
1280
1337
  * `undefined` where the body is not a file, or where the operation already declares the header.
1281
1338
  */
1282
1339
  function fileContentTypeOf(operation) {
1283
- const body = operation.parameters.body;
1340
+ const body = requestBodyParameterOf(operation);
1284
1341
  if (body?.bodyKind !== "file")
1285
1342
  return undefined;
1286
1343
  const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
@@ -1302,7 +1359,7 @@ function fileContentTypeOf(operation) {
1302
1359
  * body. A read-only property is not sent, so it contributes nothing.
1303
1360
  */
1304
1361
  function mergedBodyKeysOf(program, operation) {
1305
- const body = operation.parameters.body;
1362
+ const body = requestBodyParameterOf(operation);
1306
1363
  if (body?.bodyKind === "multipart") {
1307
1364
  return body.parts.flatMap((part) => (part.name === undefined ? [] : [part.name]));
1308
1365
  }
@@ -1313,9 +1370,86 @@ function mergedBodyKeysOf(program, operation) {
1313
1370
  .filter((property) => isPayloadProperty(program, property))
1314
1371
  .map((property) => resolveEncodedName(program, property, "application/json")));
1315
1372
  }
1373
+ const INPUT_LOCATION_NOUNS = {
1374
+ path: "path parameter",
1375
+ query: "query parameter",
1376
+ header: "header",
1377
+ cookie: "cookie",
1378
+ body: "body",
1379
+ };
1380
+ /**
1381
+ * The pairs of inputs an operation hands its handler under one key, reported once per pair and
1382
+ * program.
1383
+ *
1384
+ * **Read from the same facts the input is rendered from**: each parameter under its wire name, the
1385
+ * `Content-Type` of an `Http.File` body, and the body where {@link requestBodyPlacementOf} names it.
1386
+ * A merged body is not listed, because that decision already names the body whenever one of its keys
1387
+ * is a parameter's - which is how a `@query("body")` beside a body named `body` stays possible to
1388
+ * write, and why it is caught here rather than there.
1389
+ *
1390
+ * Resolved once per operation, since the route walk and the contract walk both read it and the
1391
+ * warning must be raised once.
1392
+ */
1393
+ function sharedInputKeysOf(program, operation) {
1394
+ const resolved = sharedInputKeys.get(operation);
1395
+ if (resolved !== undefined)
1396
+ return resolved;
1397
+ const inputs = [];
1398
+ for (const parameter of operation.parameters.parameters) {
1399
+ if (parameter.type === "path" ||
1400
+ parameter.type === "query" ||
1401
+ parameter.type === "header" ||
1402
+ parameter.type === "cookie") {
1403
+ inputs.push({ key: parameter.name, location: parameter.type, target: parameter.param });
1404
+ }
1405
+ }
1406
+ const fileContentType = fileContentTypeOf(operation);
1407
+ if (fileContentType !== undefined) {
1408
+ inputs.push({ key: "Content-Type", location: "header", target: fileContentType });
1409
+ }
1410
+ const placement = requestBodyPlacementOf(program, operation);
1411
+ const bodyKey = placement.rawBodyProperty ?? placement.bodyProperty;
1412
+ if (bodyKey !== undefined) {
1413
+ inputs.push({
1414
+ key: bodyKey,
1415
+ location: "body",
1416
+ target: requestBodyParameterOf(operation)?.property ?? operation.operation,
1417
+ });
1418
+ }
1419
+ const shared = [];
1420
+ inputs.forEach((input, index) => {
1421
+ const first = inputs.slice(0, index).find((earlier) => earlier.key === input.key);
1422
+ if (first === undefined)
1423
+ return;
1424
+ shared.push({ key: input.key, first: first.location, second: input.location });
1425
+ const format = {
1426
+ operation: operationIdOf(program, operation.operation),
1427
+ key: input.key,
1428
+ first: INPUT_LOCATION_NOUNS[first.location],
1429
+ second: INPUT_LOCATION_NOUNS[input.location],
1430
+ };
1431
+ /**
1432
+ * **Once per program, keyed on what the warning says.** Each emitter that runs the library
1433
+ * collects its own operations, over types a visibility or version projection may have cloned,
1434
+ * so neither the operation nor the declaration is the same object twice: `typespec-hono` beside
1435
+ * `typespec-http-mcp` raised the warning twice per pair.
1436
+ */
1437
+ const reported = sharedInputKeysReported.get(program) ?? new Set();
1438
+ sharedInputKeysReported.set(program, reported);
1439
+ const said = JSON.stringify(format);
1440
+ if (reported.has(said))
1441
+ return;
1442
+ reported.add(said);
1443
+ reportDiagnostic(program, { code: "shared-input-key", target: input.target, format });
1444
+ });
1445
+ sharedInputKeys.set(operation, shared);
1446
+ return shared;
1447
+ }
1448
+ const sharedInputKeys = new WeakMap();
1449
+ const sharedInputKeysReported = new WeakMap();
1316
1450
  function requestBodyPlacementOf(program, operation) {
1317
1451
  const requestType = requestBodyOf(operation);
1318
- const bodyParameter = operation.parameters.body;
1452
+ const bodyParameter = requestBodyParameterOf(operation);
1319
1453
  const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
1320
1454
  /**
1321
1455
  * A `bytes` body means the BYTES are the contract.
@@ -1506,18 +1640,19 @@ export function collectRoutes(program, registry, service) {
1506
1640
  .filter((parameter) => parameter.type === "path" && parameter.allowReserved)
1507
1641
  .map((parameter) => parameter.name),
1508
1642
  pathSegments: template.segments,
1643
+ sharedInputKeys: sharedInputKeysOf(program, operation),
1509
1644
  decodedPathParameters: operation.parameters.parameters
1510
- .filter((parameter) => decodedSegmentOf(parameter, template.segments) !== undefined)
1645
+ .filter((parameter) => decodedSegmentOf(program, parameter, template.segments) !== undefined)
1511
1646
  .map((parameter) => parameter.name),
1512
1647
  literalQuery: template.literalQuery,
1513
1648
  responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
1514
- requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
1649
+ requestContentTypes: [...(requestBodyParameterOf(operation)?.contentTypes ?? [])],
1515
1650
  // `isRawBinaryMediaType` is "every declared type is non-JSON", which is the same question
1516
1651
  // here: a string body under `application/json` IS json, and its reader is the JSON one.
1517
1652
  requestTextual: requestType !== undefined &&
1518
1653
  rawBodyProperty === undefined &&
1519
1654
  isStringType(requestType) &&
1520
- isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])]),
1655
+ isRawBinaryMediaType([...(requestBodyParameterOf(operation)?.contentTypes ?? [])]),
1521
1656
  /**
1522
1657
  * `@summary` first, `@doc` as the fallback.
1523
1658
  *
@@ -1537,7 +1672,7 @@ export function collectRoutes(program, registry, service) {
1537
1672
  requestSchema: withVisibility(program, requestVisibility, () => {
1538
1673
  // Multipart first: its body has no single type to walk, and skipping it left every
1539
1674
  // such route mounted with nothing checking the request at all.
1540
- const parameterBody = operation.parameters.body;
1675
+ const parameterBody = requestBodyParameterOf(operation);
1541
1676
  if (parameterBody?.bodyKind === "multipart") {
1542
1677
  return multipartSchemaOf(program, parameterBody, registry);
1543
1678
  }
@@ -2135,7 +2270,7 @@ function collectRequestTypes(program, registry, service) {
2135
2270
  const body = requestBodyOf(operation);
2136
2271
  const placement = requestBodyPlacementOf(program, operation);
2137
2272
  /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
2138
- const parameterBody = operation.parameters.body;
2273
+ const parameterBody = requestBodyParameterOf(operation);
2139
2274
  /**
2140
2275
  * Headers are part of the input too.
2141
2276
  *
@@ -2155,11 +2290,24 @@ function collectRequestTypes(program, registry, service) {
2155
2290
  * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
2156
2291
  * fails to typecheck against a server that is correct.
2157
2292
  */
2158
- .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
2293
+ .map((parameter) => ({
2294
+ key: parameter.name,
2295
+ line: registry.expressionForProperty(parameter.param, parameter.name),
2296
+ }));
2159
2297
  const fileContentType = fileContentTypeOf(operation);
2160
2298
  if (fileContentType !== undefined) {
2161
- extraProperties.push(`${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`);
2299
+ extraProperties.push({
2300
+ key: "Content-Type",
2301
+ line: `${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`,
2302
+ });
2162
2303
  }
2304
+ /**
2305
+ * **A key two parameters share is written once per declaration, each in its own part**,
2306
+ * so the intersection types it as both - what the validators beside it accept - where two
2307
+ * members of one object literal do not compile (TS2300). `shared-input-key` reports it.
2308
+ */
2309
+ const repeated = extraProperties.filter((property, index) => extraProperties.slice(0, index).some((earlier) => earlier.key === property.key));
2310
+ const distinct = extraProperties.filter((property) => !repeated.includes(property));
2163
2311
  const parts = [];
2164
2312
  let streamsBody = false;
2165
2313
  let bodyRef;
@@ -2195,9 +2343,11 @@ function collectRequestTypes(program, registry, service) {
2195
2343
  : `{\n\t${objectKey(placement.bodyProperty)}${placement.optionalBody ? "?" : ""}: ${expression.replaceAll("\n", "\n\t")};\n}`);
2196
2344
  }
2197
2345
  }
2198
- if (extraProperties.length > 0) {
2199
- parts.push(`{\n${extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
2346
+ if (distinct.length > 0) {
2347
+ parts.push(`{\n${distinct.map(({ line }) => `\t${line}`).join("\n")}\n}`);
2200
2348
  }
2349
+ for (const { line } of repeated)
2350
+ parts.push(`{\n\t${line}\n}`);
2201
2351
  entries.push({
2202
2352
  operationId: operationIdOf(program, operation.operation),
2203
2353
  bodyRef,
@@ -15,6 +15,21 @@ import { type Program, type Type } from "@typespec/compiler";
15
15
  * asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
16
16
  */
17
17
  export declare function applyConstraints(program: Program, expression: string, target: Type): string;
18
+ /**
19
+ * **Whether this diagnostic has already been raised against this declaration.**
20
+ *
21
+ * Keyed on the code as well as the target. Keying on the target alone once made the memo shared
22
+ * between every pattern diagnostic, so whichever ran first claimed the declaration and silenced
23
+ * the rest: measured on `^(\w+\s?)*\-$`, one warning where two were due. The ReDoS judgement has
24
+ * since moved to a linter rule, which reports per declaration by construction, so that pairing can
25
+ * no longer meet here - but a memo keyed on the target alone is the wrong shape for the next
26
+ * diagnostic added beside this one, and costs nothing to keep right.
27
+ *
28
+ * The de-duplication itself is per declaration, because emission reaches a scalar once per USE -
29
+ * 153 times for a single scalar in the reference service - and repeating one warning that often
30
+ * is noise a reader learns to skip, which is how a real one gets missed.
31
+ */
32
+ export declare function firstReportOf(program: Program, code: string, target: Type): boolean;
18
33
  /**
19
34
  * **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
20
35
  *
@@ -160,7 +160,7 @@ const reported = new WeakMap();
160
160
  * 153 times for a single scalar in the reference service - and repeating one warning that often
161
161
  * is noise a reader learns to skip, which is how a real one gets missed.
162
162
  */
163
- function firstReportOf(program, code, target) {
163
+ export function firstReportOf(program, code, target) {
164
164
  let byCode = reported.get(program);
165
165
  if (byCode === undefined) {
166
166
  byCode = new Map();
package/dist/src/lib.d.ts CHANGED
@@ -174,11 +174,17 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
174
174
  "inline-cycle": {
175
175
  readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
176
176
  };
177
+ "encode-cycle": {
178
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
179
+ };
180
+ "shared-input-key": {
181
+ readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
182
+ };
177
183
  "duplicate-declaration": {
178
184
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
179
185
  };
180
186
  }, EmitterOptions, never>;
181
- export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "inline-cycle" | "non-unicode-pattern" | "optional-prototype-name" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unenforceable-property-name" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
187
+ export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "encode-cycle" | "inline-cycle" | "non-unicode-pattern" | "optional-prototype-name" | "shared-input-key" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unenforceable-property-name" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
182
188
  "unsupported-type": {
183
189
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
184
190
  };
@@ -224,6 +230,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
224
230
  "inline-cycle": {
225
231
  readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
226
232
  };
233
+ "encode-cycle": {
234
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
235
+ };
236
+ "shared-input-key": {
237
+ readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
238
+ };
227
239
  "duplicate-declaration": {
228
240
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
229
241
  };
@@ -273,6 +285,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
273
285
  "inline-cycle": {
274
286
  readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
275
287
  };
288
+ "encode-cycle": {
289
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
290
+ };
291
+ "shared-input-key": {
292
+ readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
293
+ };
276
294
  "duplicate-declaration": {
277
295
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
278
296
  };
package/dist/src/lib.js CHANGED
@@ -380,6 +380,44 @@ const diagnostics = {
380
380
  default: paramMessage `'${"type"}' closes a reference cycle without passing through anything this emitter can declare, so the cycle has no name to reference itself by. A template instantiation is inlined rather than declared, exactly as '@typespec/openapi3' inlines it, and that emitter refuses the same spec with its own 'inline-cycle' error. Give the type a name with '@friendlyName' so it becomes a declaration, or break the cycle.`,
381
381
  },
382
382
  },
383
+ /**
384
+ * **An `@encode` chain that returns to where it started never reaches a wire type.**
385
+ *
386
+ * `@encode("x", Pong) scalar Ping` and `@encode("x", Ping) scalar Pong` compile cleanly - the
387
+ * compiler checks neither the encoding's name nor where its type leads - and the walks here
388
+ * followed `@encode` by calling themselves on the encoded type. Measured before this existed:
389
+ * `RangeError: Maximum call stack size exceeded`, "Emitter crashed! This is a bug."
390
+ *
391
+ * **An error, as `inline-cycle` is.** A value of such a scalar has no form on the wire to validate,
392
+ * so there is no validator to emit. openapi3 reads one link of the chain and publishes a schema
393
+ * with no `type` at all - `{ format: "x" }` - which states nothing about the value either.
394
+ */
395
+ "encode-cycle": {
396
+ severity: "error",
397
+ messages: {
398
+ default: paramMessage `'${"type"}' is encoded as ${"chain"}: the '@encode' chain returns to a scalar it already passed, so it never reaches a type that crosses the wire. Encode one of them as a standard scalar such as 'string', or break the cycle.`,
399
+ },
400
+ },
401
+ /**
402
+ * **Two inputs of one operation handed to its handler under one key.**
403
+ *
404
+ * A handler's input is the path, query, header and cookie groups spread into one object, with the
405
+ * body merged or named beside them. `@header("x") a: string` beside `@query("x") b: int32` therefore
406
+ * reached the handler as one `x`, the header's string replacing the validated number, while the
407
+ * input was typed `number & string`. `@typespec/http` refuses only a path and a query parameter of
408
+ * one name; the rest compiled with no diagnostic (typespec-http-graphql, 2026-10-16).
409
+ *
410
+ * **A warning, because the spec is valid and openapi3 publishes it**: the parameters are distinct on
411
+ * the wire. It is the input this package states that cannot carry both, so the key is typed and
412
+ * validated as both declarations at once, and the contract types still compile. A server emitter
413
+ * reading `EmittedRoute.sharedInputKeys` declines to mount the operation.
414
+ */
415
+ "shared-input-key": {
416
+ severity: "warning",
417
+ messages: {
418
+ default: paramMessage `'${"operation"}' takes the ${"first"} '${"key"}' and the ${"second"} '${"key"}', and a handler's input holds one value under each key, so '${"key"}' is stated as both declarations at once and accepts only a value satisfying both. Give one of them a different name.`,
419
+ },
420
+ },
383
421
  "duplicate-declaration": {
384
422
  severity: "error",
385
423
  messages: {
@@ -451,8 +451,8 @@ export class SchemaRegistry {
451
451
  * them.
452
452
  *
453
453
  * **Subtypes are deliberately NOT walked here, because nothing would be walking them.** A
454
- * discriminated base renders as `z.discriminatedUnion` over its subtypes, and rendering that
455
- * already asks the registry for each one. A loop here looked load-bearing and was measured not to
454
+ * discriminated base renders as a dispatch over its subtypes - `z.discriminatedUnion`, beside a base
455
+ * arm where its discriminator is open - and rendering that already asks the registry for each one. A loop here looked load-bearing and was measured not to
456
456
  * be: removing it left all 23 polymorphism and differential arms green.
457
457
  *
458
458
  * **Wrapped in {@link captureBackEdges} and the result discarded.** This call can land on a
package/dist/src/types.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
2
2
  import { reportDiagnostic } from "./lib.js";
3
3
  import { literal } from "./literal.js";
4
- import { discriminatedSubtypes, effectiveIndexer, emitsDefault, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
4
+ import { discriminatedBaseOf, discriminatedSubtypes, effectiveIndexer, emitsDefault, encodeCycleOf, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
5
5
  /**
6
6
  * TypeSpec type -> plain TypeScript type text.
7
7
  *
@@ -75,8 +75,12 @@ function refuse(program, type, why, remedy = "") {
75
75
  function scalarToTs(program, scalar) {
76
76
  // A scalar declaration can carry the encoding itself - see `scalarToZod`.
77
77
  const encoded = encodedTypeOf(program, scalar);
78
- if (encoded !== undefined && encoded !== scalar)
79
- return scalarToTs(program, encoded);
78
+ if (encoded !== undefined && encoded !== scalar) {
79
+ // Reported once, by the schema walk - see `encodeCycleOf`.
80
+ return encodeCycleOf(program, scalar) === undefined
81
+ ? scalarToTs(program, encoded)
82
+ : UNREPRESENTABLE;
83
+ }
80
84
  let current = scalar;
81
85
  // Walk `extends` so `scalar TrimmedString extends string` resolves to the primitive underneath.
82
86
  while (current !== undefined) {
@@ -153,11 +157,26 @@ function modelToTs(program, model) {
153
157
  * the emitter disagreeing with itself - one TypeSpec, walked twice - and this is the second defect
154
158
  * of that class it has found. The first was a vocabulary inlined by one walk and referenced by the
155
159
  * other.
160
+ *
161
+ * **An open base adds itself as the last arm**, as `modelToZod` does: a value whose discriminator
162
+ * names no subtype is the base's own object. The base arm's discriminator keeps its declared type
163
+ * here; TypeScript cannot say "a string other than these", and the validator's refinement does not
164
+ * change what it accepts as input, so `z.input` and this type stay identical.
156
165
  */
157
- const subtypes = discriminatedSubtypes(program, model);
158
- if (subtypes.length > 0) {
159
- return subtypes.map((derived) => typeToTs(program, derived)).join(" | ");
166
+ const base = discriminatedBaseOf(program, model);
167
+ if (base !== undefined) {
168
+ const arms = discriminatedSubtypes(program, model).map((derived) => typeToTs(program, derived));
169
+ if (base.open)
170
+ arms.push(modelObjectToTs(program, model, base));
171
+ return arms.join(" | ");
160
172
  }
173
+ return modelObjectToTs(program, model);
174
+ }
175
+ /**
176
+ * A model as the object of its own properties, inherited ones included - for a discriminated base,
177
+ * its base arm, with the `string` discriminator openapi3 adds where the base declares none.
178
+ */
179
+ function modelObjectToTs(program, model, base) {
161
180
  const indexer = effectiveIndexer(model);
162
181
  const indexerKey = indexer?.key.name;
163
182
  // Inherited properties included - see `inheritedAndOwnProperties`. Both artefacts describe the
@@ -214,6 +233,10 @@ function modelToTs(program, model) {
214
233
  .filter((property) => isPayloadProperty(program, property))
215
234
  .filter((property) => !isNeverType(property.type))
216
235
  .map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
236
+ if (base !== undefined &&
237
+ !declared.some((property) => resolveEncodedName(program, property, "application/json") === base.propertyName)) {
238
+ entries.push(`\t${objectKey(base.propertyName)}: string;`);
239
+ }
217
240
  /**
218
241
  * **The indexer is deliberately NOT intersected in.**
219
242
  *
package/dist/src/zod.d.ts CHANGED
@@ -109,6 +109,16 @@ export declare function described(expression: string, doc: string | undefined):
109
109
  * Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
110
110
  */
111
111
  export declare function scalarDocOf(program: Program, scalar: Scalar): string | undefined;
112
+ /** A scalar's schema without its description, for a position that publishes its own. */
113
+ /**
114
+ * The scalars an `@encode` chain passes through, when it returns to one it already passed - and
115
+ * `undefined` when it ends at a type that crosses the wire.
116
+ *
117
+ * **The one guard for every walk that follows `@encode`.** The schema, the wire kind and the contract
118
+ * type each follow the chain by calling themselves on the encoded type, and each one overflowed the
119
+ * stack on `Ping -> Pong -> Ping`. A scalar encoded as itself is no cycle: the chain ends there.
120
+ */
121
+ export declare function encodeCycleOf(program: Program, scalar: Scalar): readonly Scalar[] | undefined;
112
122
  export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
113
123
  /**
114
124
  * Every property the model declares, **including the ones it inherits**.
@@ -142,6 +152,40 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
142
152
  * for a real subtype - the same rule that decides whether a base may be sealed.
143
153
  */
144
154
  export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
155
+ /**
156
+ * **How a `@discriminator` base is validated: a value whose discriminator names a subtype as that
157
+ * subtype, and any other value as the base model itself.**
158
+ *
159
+ * The document publishes the base as an object of its own properties carrying `discriminator:
160
+ * {propertyName, mapping}`, and every subtype as `allOf` that base. The mapping says which subtype a
161
+ * value naming it is; nothing in the document refuses a value naming none. This emitter dispatched
162
+ * on the mapping and stopped there, so `{ "kind": "wrongKind", "weight": 8 }` was refused for a
163
+ * `Dog` whose `kind` is `string | "golden"`, though `Dog` requires only `kind` and `weight`. That is
164
+ * the response `@typespec/http-specs` declares for `type/model/inheritance/enum-discriminator`,
165
+ * `single-discriminator` and `nested-discriminator`, and a handler returning it failed its own
166
+ * response contract (typespec-http-graphql, 2026-10-16).
167
+ *
168
+ * **A closed discriminator every value of which a subtype claims has no base arm**: a value outside
169
+ * it is refused by the base's own discriminator, and a value inside it always names a subtype. So a
170
+ * fixed enum fully covered by its subtypes is validated exactly as before.
171
+ */
172
+ export interface DiscriminatedBase {
173
+ /** The discriminator's wire name. */
174
+ readonly propertyName: string;
175
+ /** The subtypes a `z.discriminatedUnion` dispatches to, each carrying literal discriminator values. */
176
+ readonly dispatched: readonly Model[];
177
+ /**
178
+ * Subtypes that are themselves bases validated with a base arm. A `z.union` has no literal values
179
+ * to dispatch on, so no `z.discriminatedUnion` can hold one; it sits beside the dispatch, where its
180
+ * own literal discriminator keeps it exclusive.
181
+ */
182
+ readonly beside: readonly Model[];
183
+ /** Every value a subtype claims, which are the keys of the document's `mapping`. */
184
+ readonly claimed: readonly string[];
185
+ /** Whether a value no subtype claims is validated as the base model. */
186
+ readonly open: boolean;
187
+ }
188
+ export declare function discriminatedBaseOf(program: Program, model: Model): DiscriminatedBase | undefined;
145
189
  export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
146
190
  /**
147
191
  * A name as an object key - quoted when it is not a legal JavaScript identifier.
package/dist/src/zod.js CHANGED
@@ -1,6 +1,6 @@
1
- import { getDiscriminatedUnion, getDiscriminator, getDoc, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
1
+ import { getDiscriminatedUnion, getDiscriminatedUnionFromInheritance, getDiscriminator, getDoc, getEncode, ignoreDiagnostics, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
2
2
  import { createMetadataInfo, getCookieParamOptions, getHeaderFieldName, getPathParamName, getQueryParamName, isCookieParam, isHeader, isPathParam, isQueryParam, Visibility, } from "@typespec/http";
3
- import { applyConstraints } from "./constraints.js";
3
+ import { applyConstraints, firstReportOf } from "./constraints.js";
4
4
  import { literal } from "./literal.js";
5
5
  import { reportDiagnostic } from "./lib.js";
6
6
  /**
@@ -453,10 +453,48 @@ export function scalarDocOf(program, scalar) {
453
453
  return undefined;
454
454
  }
455
455
  /** A scalar's schema without its description, for a position that publishes its own. */
456
+ /**
457
+ * The scalars an `@encode` chain passes through, when it returns to one it already passed - and
458
+ * `undefined` when it ends at a type that crosses the wire.
459
+ *
460
+ * **The one guard for every walk that follows `@encode`.** The schema, the wire kind and the contract
461
+ * type each follow the chain by calling themselves on the encoded type, and each one overflowed the
462
+ * stack on `Ping -> Pong -> Ping`. A scalar encoded as itself is no cycle: the chain ends there.
463
+ */
464
+ export function encodeCycleOf(program, scalar) {
465
+ const passed = [];
466
+ for (let current = scalar; current !== undefined;) {
467
+ if (passed.includes(current))
468
+ return [...passed, current];
469
+ passed.push(current);
470
+ const encoded = encodedTypeOf(program, current);
471
+ current = encoded === current ? undefined : encoded;
472
+ }
473
+ return undefined;
474
+ }
456
475
  function scalarSchemaOf(program, scalar) {
457
476
  // `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
458
477
  const encoded = encodedTypeOf(program, scalar);
459
478
  if (encoded !== undefined && encoded !== scalar) {
479
+ const cycle = encodeCycleOf(program, scalar);
480
+ if (cycle !== undefined) {
481
+ // Reached once per use; reported once per scalar.
482
+ if (firstReportOf(program, "encode-cycle", scalar)) {
483
+ reportDiagnostic(program, {
484
+ code: "encode-cycle",
485
+ target: scalar,
486
+ format: {
487
+ type: scalar.name,
488
+ // From the first link on: `Ping` is encoded as `'Pong' -> 'Ping'`.
489
+ chain: cycle
490
+ .slice(1)
491
+ .map((link) => `'${link.name}'`)
492
+ .join(" -> "),
493
+ },
494
+ });
495
+ }
496
+ return UNREPRESENTABLE;
497
+ }
460
498
  return applyConstraints(program, scalarSchemaOf(program, encoded), scalar);
461
499
  }
462
500
  let current = scalar;
@@ -726,31 +764,130 @@ export function discriminatedSubtypes(program, model) {
726
764
  return [];
727
765
  return model.derivedModels.filter((derived) => includeDerivedModel(derived));
728
766
  }
767
+ export function discriminatedBaseOf(program, model) {
768
+ const discriminator = getDiscriminator(program, model);
769
+ const subtypes = discriminatedSubtypes(program, model);
770
+ if (discriminator === undefined || subtypes.length === 0)
771
+ return undefined;
772
+ /**
773
+ * The compiler's own reading of which subtype claims which value, which is what openapi3 writes
774
+ * the `mapping` from.
775
+ */
776
+ const claimed = [
777
+ ...ignoreDiagnostics(getDiscriminatedUnionFromInheritance(model, discriminator)).variants.keys(),
778
+ ];
779
+ const declared = discriminatorPropertyOf(program, model, discriminator.propertyName);
780
+ /**
781
+ * Undeclared, the discriminator is the `string` openapi3 adds to the base - open. Declared, it is
782
+ * closed only where its type is a finite set of strings.
783
+ */
784
+ const values = declared === undefined ? undefined : closedValuesOf(declared.type);
785
+ const beside = subtypes.filter((subtype) => isValidatedWithBaseArm(program, subtype));
786
+ return {
787
+ propertyName: discriminator.propertyName,
788
+ dispatched: subtypes.filter((subtype) => !beside.includes(subtype)),
789
+ beside,
790
+ claimed,
791
+ open: values === undefined || values.some((value) => !claimed.includes(value)),
792
+ };
793
+ }
794
+ /** Whether a model is a base validated as a `z.union` rather than a `z.discriminatedUnion`. */
795
+ function isValidatedWithBaseArm(program, model) {
796
+ const base = discriminatedBaseOf(program, model);
797
+ return base !== undefined && (base.open || base.beside.length > 0);
798
+ }
799
+ /** The base's own discriminator property, matched by wire name as openapi3 matches it. */
800
+ function discriminatorPropertyOf(program, model, propertyName) {
801
+ return inheritedAndOwnProperties(model).find((property) => rawPropertyKey(program, property) === propertyName);
802
+ }
803
+ /** The strings a type admits, where they are finite; `undefined` where it admits any string. */
804
+ function closedValuesOf(type) {
805
+ if (type.kind === "String")
806
+ return [type.value];
807
+ if (type.kind === "EnumMember") {
808
+ const value = type.value ?? type.name;
809
+ return typeof value === "string" ? [value] : undefined;
810
+ }
811
+ const members = type.kind === "Enum"
812
+ ? [...type.members.values()]
813
+ : type.kind === "Union"
814
+ ? [...type.variants.values()].map((variant) => variant.type)
815
+ : undefined;
816
+ if (members === undefined)
817
+ return undefined;
818
+ const values = [];
819
+ for (const member of members) {
820
+ const closed = closedValuesOf(member);
821
+ if (closed === undefined)
822
+ return undefined;
823
+ values.push(...closed);
824
+ }
825
+ return values;
826
+ }
827
+ /**
828
+ * The base arm's discriminator, refused for every value a subtype claims.
829
+ *
830
+ * **Without this a known kind falls through to the base.** `z.union` tries the dispatch and then the
831
+ * base arm, and a base with subtypes is never sealed, so `{kind: "sparrow", wingspan: 1, x: 1}` -
832
+ * refused by the sealed `Sparrow` - would be accepted as a `Bird` with `x` stripped. Guarded, a value
833
+ * naming a subtype is validated as that subtype and nothing else.
834
+ *
835
+ * `abort` keeps this refusal out of the error a caller reads: Zod reports the one arm that did not
836
+ * abort, which for a known kind is the subtype that was actually broken.
837
+ */
838
+ function guardedDiscriminator(schema, base, model) {
839
+ if (base.claimed.length === 0)
840
+ return schema;
841
+ const test = base.claimed.map((value) => `value !== ${literal(value)}`).join(" && ");
842
+ const error = literal(`Validated as the subtype this '${base.propertyName}' names, not as '${model.name}'.`);
843
+ return `${schema}.refine((value) => ${test}, { abort: true, error: ${error} })`;
844
+ }
729
845
  function modelToZod(program, model) {
730
846
  /**
731
- * **A `@discriminator` base is a CHOICE between its subtypes, not a shape of its own.**
847
+ * **A `@discriminator` base is a CHOICE between its subtypes, and its own shape for any value
848
+ * naming none of them.** See {@link DiscriminatedBase}.
732
849
  *
733
850
  * `@discriminator("kind") model Bird { kind: string; wingspan: int32 }` with four models extending
734
851
  * it reaches OpenAPI as a component carrying `discriminator: {propertyName, mapping}`, and the
735
852
  * mapping is an instruction: validate the body against whichever subtype the discriminator names.
736
- * Emitting the base's own properties instead produced `z.object({kind: z.string(), wingspan})`,
853
+ * Emitting the base's own properties alone produced `z.object({kind: z.string(), wingspan})`,
737
854
  * which accepts `{kind: "eagle"}` while ignoring everything `Eagle` actually declares - a
738
855
  * polymorphic endpoint validating none of its polymorphism.
739
856
  *
740
- * `z.discriminatedUnion` is the faithful encoding and composes both ways this corpus needs.
741
- * Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
857
+ * `z.discriminatedUnion` is the faithful encoding of the dispatch and composes both ways this
858
+ * corpus needs. Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
742
859
  * `Fish -> Shark -> SawShark` works; and an option carrying a getter that refers back to the union
743
860
  * being defined does **not** throw, because the discriminator is read without enumerating the rest
744
861
  * of the shape. `Eagle.friends?: Bird[]` is exactly that, and it decides whether polymorphism and
745
862
  * recursion can coexist at all.
863
+ *
864
+ * **`z.discriminatedUnion` has no arm for "any other value"**, and `unionFallback` cannot add one:
865
+ * it retries the same options, each of which must carry literal discriminator values. So an open
866
+ * base is `z.union([dispatch, ...nested bases, base arm])`, every arm exclusive on the
867
+ * discriminator - each subtype by its literal, the base arm by {@link guardedDiscriminator}.
746
868
  */
747
- const subtypes = discriminatedSubtypes(program, model);
748
- if (subtypes.length > 0) {
749
- // `discriminatedSubtypes` is empty unless the model has a discriminator.
750
- const discriminator = getDiscriminator(program, model)?.propertyName ?? "";
751
- const options = subtypes.map((derived) => typeToZod(program, derived));
752
- return `z.discriminatedUnion(${literal(discriminator)}, [${options.join(", ")}])`;
869
+ const base = discriminatedBaseOf(program, model);
870
+ if (base !== undefined) {
871
+ const arms = [];
872
+ if (base.dispatched.length > 0) {
873
+ const options = base.dispatched.map((derived) => typeToZod(program, derived));
874
+ arms.push(`z.discriminatedUnion(${literal(base.propertyName)}, [${options.join(", ")}])`);
875
+ }
876
+ arms.push(...base.beside.map((derived) => typeToZod(program, derived)));
877
+ if (base.open)
878
+ arms.push(modelObjectToZod(program, model, base));
879
+ return arms.length === 1 ? (arms[0] ?? UNREPRESENTABLE) : `z.union([${arms.join(", ")}])`;
753
880
  }
881
+ return modelObjectToZod(program, model);
882
+ }
883
+ /**
884
+ * A model as the object of its own properties, inherited ones included.
885
+ *
886
+ * **Given a discriminated base, this is its base arm**: the same object, with its discriminator
887
+ * refused for every value a subtype claims, and - where the base does not declare the discriminator -
888
+ * the required `string` openapi3 adds to the base's properties in its place.
889
+ */
890
+ function modelObjectToZod(program, model, base) {
754
891
  const indexer = effectiveIndexer(model);
755
892
  const indexerKey = indexer?.key.name;
756
893
  const declared = inheritedAndOwnProperties(model);
@@ -865,13 +1002,23 @@ function modelToZod(program, model) {
865
1002
  * parameters for the reason `renderSchemas` names both: one leaves the input side `unknown`.
866
1003
  */
867
1004
  const annotation = deferred ? (annotateDeferred(property) ?? "") : "";
1005
+ const guarded = base !== undefined && rawPropertyKey(program, property) === base.propertyName
1006
+ ? guardedDiscriminator(value, base, model)
1007
+ : value;
868
1008
  return {
869
1009
  deferred,
870
1010
  text: deferred
871
- ? `\tget ${key}()${annotation} {\n\t\treturn ${value};\n\t},`
872
- : `\t${key}: ${value},`,
1011
+ ? `\tget ${key}()${annotation} {\n\t\treturn ${guarded};\n\t},`
1012
+ : `\t${key}: ${guarded},`,
873
1013
  };
874
1014
  });
1015
+ if (base !== undefined &&
1016
+ discriminatorPropertyOf(program, model, base.propertyName) === undefined) {
1017
+ members.push({
1018
+ deferred: false,
1019
+ text: `\t${objectKey(base.propertyName)}: ${guardedDiscriminator("z.string()", base, model)},`,
1020
+ });
1021
+ }
875
1022
  const entries = members.map((member) => member.text);
876
1023
  /**
877
1024
  * A model-level `@refine` goes through the same path as every other constraint, so the predicate's
@@ -950,8 +1097,10 @@ const NUMERIC_EXPRESSIONS = ["z.number()", "z.int(", "z.int32(", "z.uint32("];
950
1097
  export function wireKindOf(program, type) {
951
1098
  if (type.kind === "Scalar") {
952
1099
  const encoded = encodedTypeOf(program, type);
953
- if (encoded !== undefined && encoded !== type)
954
- return wireKindOf(program, encoded);
1100
+ if (encoded !== undefined && encoded !== type) {
1101
+ // Reported by the schema walk; a chain with no end has no kind to decode into.
1102
+ return encodeCycleOf(program, type) === undefined ? wireKindOf(program, encoded) : undefined;
1103
+ }
955
1104
  for (let current = type; current !== undefined; current = current.baseScalar) {
956
1105
  const mapped = SCALARS[current.name];
957
1106
  if (mapped === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.29.0",
3
+ "version": "0.30.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",