typespec-http-zod 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,14 @@
1
1
  import { type ModelProperty, type Program, type Type } from "@typespec/compiler";
2
+ import { type Merged, type PropertyPosition } from "./zod.js";
3
+ /**
4
+ * **A type the document publishes that no TypeSpec type stands for** - an envelope, `PetCat` -
5
+ * declared under its name by the registry, as the schema registry declares its validator.
6
+ */
7
+ export declare function withTsSyntheticDeclarations<T>(hook: (name: string, build: () => string, site: Type) => string, run: () => T): T;
8
+ /**
9
+ * A merged value as TypeScript - `z.input` of what `mergedToZod` writes for it, node for node.
10
+ */
11
+ export declare function mergedToTs(program: Program, value: Merged): string;
2
12
  /**
3
13
  * A property line, including its optionality marker.
4
14
  *
@@ -15,8 +25,8 @@ import { type ModelProperty, type Program, type Type } from "@typespec/compiler"
15
25
  * Shared with the annotation the registry writes on a deferred (getter) property, so the type that
16
26
  * annotation claims is by construction the type this walk renders for the same property.
17
27
  */
18
- export declare function propertyTypeToTs(program: Program, property: ModelProperty): string;
19
- export declare function propertyToTs(program: Program, property: ModelProperty, wireName?: string): string;
28
+ export declare function propertyTypeToTs(program: Program, property: ModelProperty, position?: PropertyPosition): string;
29
+ export declare function propertyToTs(program: Program, property: ModelProperty, wireName?: string, position?: PropertyPosition): string;
20
30
  export declare function withTsRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
21
31
  export declare function typeToTs(program: Program, type: Type): string;
22
32
  /**
package/dist/src/types.js CHANGED
@@ -1,7 +1,8 @@
1
- import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
1
+ import { getDiscriminatedUnion, resolveEncodedName, } from "@typespec/compiler";
2
+ import { firstReportOf } from "./constraints.js";
2
3
  import { reportDiagnostic } from "./lib.js";
3
4
  import { literal } from "./literal.js";
4
- import { discriminatedSubtypes, effectiveIndexer, emitsDefault, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
5
+ import { ANY_JSON_TS, atDeclaration, atItem, atPosition, atReading, constrainedMembersOf, constrainingKeywords, discriminatedBaseOf, discriminatedSubtypes, dispatchedVariantsOf, effectiveIndexer, emitsDefault, encodedIntersects, encodedValueOf, envelopeNameOf, envelopeVisibilityOf, exclusiveSiteOf, holdsRefusedMember, implicitlyOptional, inParameterPosition, isExclusiveHere, isOptionalAt, isRawBytes, isTypelessMember, multipartBinaryContext, keptMembersOf, LIST_TEMPLATE_REMEDY, listTemplateInstanceWhy, membersToWrite, nullableInnerOf, objectKey, objectOfModel, OWN_POSITION, refusalTarget, replacesFormOf, scalarEncodingOf, sealedBaseExtensionOf, TUPLE_PARAMETER, undeclaredVariantOf, unsatisfiableSubtype, withDeclarationSite, withExclusiveSite, withVisibility, } from "./zod.js";
5
6
  /**
6
7
  * TypeSpec type -> plain TypeScript type text.
7
8
  *
@@ -65,22 +66,29 @@ const UNREPRESENTABLE = "never";
65
66
  * `withDeclarationSite` in `zod.ts`, whose scope this walk shares.
66
67
  */
67
68
  function refuse(program, type, why, remedy = "") {
68
- reportDiagnostic(program, {
69
- code: "unsupported-type",
70
- target: refusalTarget(type),
71
- format: { artefact: "a TypeScript type", kind: type.kind, why, remedy },
72
- });
69
+ // Once per declaration, whichever walk reaches it first: both walk every type.
70
+ const target = refusalTarget(type);
71
+ if (firstReportOf(program, "unsupported-type", target, `${type.kind} ${why}`)) {
72
+ reportDiagnostic(program, {
73
+ code: "unsupported-type",
74
+ target,
75
+ format: { artefact: "a TypeScript type", kind: type.kind, why, remedy },
76
+ });
77
+ }
73
78
  return UNREPRESENTABLE;
74
79
  }
75
80
  function scalarToTs(program, scalar) {
76
- // A scalar declaration can carry the encoding itself - see `scalarToZod`.
77
- const encoded = encodedTypeOf(program, scalar);
78
- if (encoded !== undefined && encoded !== scalar)
81
+ // A scalar declaration can carry the encoding itself, or inherit it - see `scalarEncodingOf`.
82
+ const encoded = scalarEncodingOf(program, scalar)?.encoded;
83
+ // A scalar that is not standard states no type; the schema walk reports it.
84
+ if (encoded !== undefined && !program.checker.isStdType(encoded))
85
+ return UNREPRESENTABLE;
86
+ if (encoded !== undefined)
79
87
  return scalarToTs(program, encoded);
80
88
  let current = scalar;
81
89
  // Walk `extends` so `scalar TrimmedString extends string` resolves to the primitive underneath.
82
90
  while (current !== undefined) {
83
- const mapped = SCALARS[current.name];
91
+ const mapped = program.checker.isStdType(current) ? SCALARS[current.name] : undefined;
84
92
  if (mapped !== undefined)
85
93
  return mapped;
86
94
  current = current.baseScalar;
@@ -101,6 +109,22 @@ function scalarToTs(program, scalar) {
101
109
  */
102
110
  return "unknown";
103
111
  }
112
+ /** Declares a shape no type stands for under a name - see `withTsSyntheticDeclarations`. */
113
+ let declareSynthetic;
114
+ /**
115
+ * **A type the document publishes that no TypeSpec type stands for** - an envelope, `PetCat` -
116
+ * declared under its name by the registry, as the schema registry declares its validator.
117
+ */
118
+ export function withTsSyntheticDeclarations(hook, run) {
119
+ const previous = declareSynthetic;
120
+ declareSynthetic = hook;
121
+ try {
122
+ return run();
123
+ }
124
+ finally {
125
+ declareSynthetic = previous;
126
+ }
127
+ }
104
128
  function enumToTs(target) {
105
129
  const values = [...target.members.values()].map((member) => member.value ?? member.name);
106
130
  return values.map((value) => literal(value)).join(" | ");
@@ -115,17 +139,39 @@ function unionToTs(program, union) {
115
139
  const [discriminated] = getDiscriminatedUnion(program, union);
116
140
  if (discriminated !== undefined && discriminated.defaultVariant === undefined) {
117
141
  const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
118
- const arms = [...discriminated.variants.entries()].map(([name, variant]) => {
119
- const tag = `${objectKey(discriminatorPropertyName)}: ${literal(name)};`;
142
+ // Refused by the Zod walk as `undeclared-discriminator`, and `never` on both sides.
143
+ if (envelope === "none" &&
144
+ [...discriminated.variants.values()].some((variant) => {
145
+ const why = undeclaredVariantOf(program, variant, discriminatorPropertyName);
146
+ return why === "missing" || why === "open";
147
+ })) {
148
+ return UNREPRESENTABLE;
149
+ }
150
+ // A variant no value can be is dropped, as `unionToZod` drops it.
151
+ const variants = dispatchedVariantsOf(program, union);
152
+ if (variants.length === 0)
153
+ return UNREPRESENTABLE;
154
+ const arms = variants.map(([name, variant]) => {
155
+ // Optional where the envelope is published at a PATCH - see `envelopeVisibilityOf`.
156
+ const optional = implicitlyOptional(envelopeVisibilityOf(program, union, variant)) ? "?" : "";
157
+ const tag = `${objectKey(discriminatorPropertyName)}${optional}: ${literal(name)};`;
120
158
  const inner = typeToTs(program, variant);
121
159
  // No injection: with no envelope the variant declares the discriminator itself, or the union
122
160
  // is refused in `zod.ts`. Nothing is added that the document does not publish.
123
- return envelope === "none"
124
- ? inner
125
- : `{ ${tag} ${objectKey(envelopePropertyName)}: ${inner}; }`;
161
+ if (envelope === "none")
162
+ return inner;
163
+ const wrapper = `{ ${tag} ${objectKey(envelopePropertyName)}${optional}: ${inner}; }`;
164
+ // Declared under the component openapi3 publishes it as, as the validator is.
165
+ const declared = envelopeNameOf(program, union, name, variant);
166
+ return declared === undefined
167
+ ? wrapper
168
+ : (declareSynthetic?.(declared, () => wrapper, union) ?? wrapper);
126
169
  });
127
170
  return arms.join(" | ");
128
171
  }
172
+ // Refused with the validator, which says why - see `holdsRefusedMember`.
173
+ if (holdsRefusedMember(program, union, isExclusiveHere(program, union)))
174
+ return UNREPRESENTABLE;
129
175
  const variants = [...union.variants.values()].map((variant) => variant.type);
130
176
  if (variants.length === 0) {
131
177
  reportDiagnostic(program, {
@@ -135,7 +181,10 @@ function unionToTs(program, union) {
135
181
  });
136
182
  return UNREPRESENTABLE;
137
183
  }
138
- return variants.map((type) => typeToTs(program, type)).join(" | ");
184
+ // A raw `bytes` member of a multipart part's union is any value - see `withMultipartBinary`.
185
+ return variants
186
+ .map((type) => multipartBinaryContext() && isRawBytes(program, type) ? "unknown" : typeToTs(program, type))
187
+ .join(" | ");
139
188
  }
140
189
  function modelToTs(program, model) {
141
190
  /**
@@ -153,67 +202,80 @@ function modelToTs(program, model) {
153
202
  * the emitter disagreeing with itself - one TypeSpec, walked twice - and this is the second defect
154
203
  * of that class it has found. The first was a vocabulary inlined by one walk and referenced by the
155
204
  * other.
205
+ *
206
+ * **The base itself is the last arm**, as `modelToZod` writes it: a value that is no valid instance
207
+ * of the subtype its discriminator names is the base's own object, which is what the document
208
+ * validates it as. `z.input` of the union and this type are therefore identical.
156
209
  */
157
- const subtypes = discriminatedSubtypes(program, model);
158
- if (subtypes.length > 0) {
159
- return subtypes.map((derived) => typeToTs(program, derived)).join(" | ");
210
+ const base = discriminatedBaseOf(program, model);
211
+ if (base !== undefined) {
212
+ const arms = discriminatedSubtypes(program, model).map((derived) => typeToTs(program, derived));
213
+ arms.push(modelObjectToTs(program, model));
214
+ return arms.join(" | ");
160
215
  }
216
+ return modelObjectToTs(program, model);
217
+ }
218
+ /**
219
+ * A model as the object of its own properties, inherited ones included - for a discriminated base,
220
+ * the base, with the `string` discriminator openapi3 adds where the base declares none.
221
+ */
222
+ function modelObjectToTs(program, model) {
223
+ // Refused by the Zod walk - `sealed-base-extended`, `invalid-subtype-discriminator` - and `never`
224
+ // on both sides.
225
+ const extension = sealedBaseExtensionOf(program, model);
226
+ if (extension?.required === true || unsatisfiableSubtype(program, model))
227
+ return UNREPRESENTABLE;
161
228
  const indexer = effectiveIndexer(model);
162
- const indexerKey = indexer?.key.name;
163
- // Inherited properties included - see `inheritedAndOwnProperties`. Both artefacts describe the
164
- // same model, so a base whose properties reach one and not the other is a contradiction we ship.
165
- const declared = inheritedAndOwnProperties(model);
166
- if (indexerKey === "integer" && indexer !== undefined) {
167
- const element = typeToTs(program, indexer.value);
229
+ if (indexer?.key.name === "integer") {
230
+ const element = atItem(program, model, () => typeToTs(program, indexer.value));
168
231
  // Parenthesised so `A | B` as an element does not become `A | B[]`.
169
232
  return /[|&\s]/.test(element) ? `(${element})[]` : `${element}[]`;
170
233
  }
234
+ // Inherited properties included, each judged where it is published, and the discriminator openapi3
235
+ // adds - the object `modelObjectToZod` writes, from the one `objectOfModel` both walks read.
236
+ return mergedToTs(program, objectOfModel(program, model));
237
+ }
238
+ /**
239
+ * An object as TypeScript: the same members `objectToZod` writes, a dictionary as a `Record`.
240
+ *
241
+ * **A string indexer only means "dictionary" when the model declares NO properties.** The same trap
242
+ * `modelToZod` had, and it bites harder here: every permissive response model is
243
+ * `{ ...pinned fields; ...Record<unknown> }`, so reading the indexer first would type all 97 of them
244
+ * as a bare `Record<string, unknown>` - a type that satisfies any assertion about what the response
245
+ * contains, which is precisely the check this file exists to feed.
246
+ *
247
+ * **Metadata, invisible and `never` properties are left out here, as on the Zod side, and the two
248
+ * must agree** - both read one member list. Two walks over one TypeSpec have to agree about which
249
+ * properties exist, or `wire-contract.gen.ts` pairs a validator against a type describing a
250
+ * different shape.
251
+ */
252
+ /** Whether a merged dictionary is written as an object of its own - see `mergedToTs`. */
253
+ let spellingDictionaries = false;
254
+ function objectToTs(program, object) {
255
+ const { rest } = object;
171
256
  /**
172
- * **A string indexer only means "dictionary" when the model declares NO properties.**
173
- *
174
- * The same trap `modelToZod` had, and it bites harder here: every permissive response model is
175
- * `{ ...pinned fields; ...Record<unknown> }`, so reading the indexer first would type all 97 of them
176
- * as a bare `Record<string, unknown>` - a type that satisfies any assertion about what the response
177
- * contains, which is precisely the check this file exists to feed.
257
+ * **A dictionary is an object with an indexer and no member where it is used** - the wire
258
+ * assertion's own reading of `z.input`. A model whose only properties are a `@header` or a
259
+ * create-only key, returned by a GET, is validated as `z.object({}).catchall(...)` there, and was
260
+ * typed `{}` for declaring properties, which the assertion refused.
178
261
  */
179
- // `Record<never>` states closedness rather than a value type - see `modelToZod`. A model that is
180
- // only that is an object with no declared properties, not a dictionary of an unwalkable type.
181
- if (indexerKey === "string" &&
182
- indexer !== undefined &&
183
- !isNeverType(indexer.value) &&
184
- declared.length === 0) {
185
- return `Record<string, ${typeToTs(program, indexer.value)}>`;
262
+ const dictionary = object.dictionary || object.members.length === 0;
263
+ // The library's `Record`, whatever the spec names its own - see `LIBRARY_NAMES_WRITTEN_BARE` -
264
+ // or an object of its own, where a union writes it twice - see `mergedToTs`.
265
+ const dictionaryOf = (value) => spellingDictionaries ? `{ [key: string]: ${value} }` : `globalThis.Record<string, ${value}>`;
266
+ if (dictionary && rest === "loose")
267
+ return dictionaryOf("unknown");
268
+ if (dictionary && typeof rest !== "string") {
269
+ const value = mergedToTs(program, rest);
270
+ /**
271
+ * A dictionary of nothing admits only `{}`, the empty object an empty model is written as;
272
+ * `Record<string, never>` is how Zod infers both, and the wire contract reads it as `{}`.
273
+ */
274
+ return value === UNREPRESENTABLE ? "{}" : dictionaryOf(value);
186
275
  }
187
- /**
188
- * **A `never` property is dropped here too, and this walk is where the refusal actually came
189
- * from.** `modelToZod` filters it because `@typespec/openapi3` omits such a property from the
190
- * document entirely - `model N { value: never; other: string }` publishes as `{other}` with
191
- * `required: ["other"]`. This walk had no property filter at all, so it reached `typeToTs`, hit the
192
- * `Intrinsic` case and reported `unsupported-type` from a type with no source node - which is why
193
- * the diagnostic pointed at `<unknown location>` and why filtering the Zod side alone did not
194
- * silence it.
195
- *
196
- * Two walks over one TypeSpec have to agree about which properties exist, or `wire-contract.gen.ts`
197
- * pairs a validator against a type describing a different shape. That assertion is the only thing
198
- * that catches this emitter disagreeing with itself, and it cannot catch a disagreement that
199
- * aborts the compile first.
200
- */
201
- /**
202
- * **Metadata is filtered out here, as it is on the Zod side, and the two must agree.**
203
- *
204
- * `metadataInfo.isPayloadProperty` is what decides whether a property is in the BODY at this
205
- * position: a `@query` or `@header` property is carried separately, and an `@invisible` one is
206
- * carried nowhere. `modelToZod` has asked since it was written; this walk never did, so a
207
- * contract type listed properties the validator beside it refuses as unknown keys.
208
- *
209
- * **Applied AFTER the dictionary check above, deliberately.** That check reads the UNFILTERED
210
- * list, because a model whose only properties are metadata is still a declared object rather than
211
- * a dictionary, and filtering first would flip it to `Record<string, T>` on one side alone.
212
- */
213
- const entries = declared
214
- .filter((property) => isPayloadProperty(program, property))
215
- .filter((property) => !isNeverType(property.type))
216
- .map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
276
+ const entries = object.members.map(({ key, property, position }) => property === undefined
277
+ ? `\t${objectKey(key)}${position.required ? "" : "?"}: ${mergedToTs(program, position.value ?? { kind: "never" })};`
278
+ : `\t${withDeclarationSite(property, () => atPosition(position, () => atDeclaration(program, property, () => propertyToTs(program, property, undefined, position))))}`);
217
279
  /**
218
280
  * **The indexer is deliberately NOT intersected in.**
219
281
  *
@@ -246,6 +308,73 @@ function modelToTs(program, model) {
246
308
  */
247
309
  return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
248
310
  }
311
+ /**
312
+ * A merged value as TypeScript - `z.input` of what `mergedToZod` writes for it, node for node.
313
+ */
314
+ export function mergedToTs(program, value) {
315
+ switch (value.kind) {
316
+ case "never":
317
+ case "refused":
318
+ return UNREPRESENTABLE;
319
+ case "type": {
320
+ const typed = () => typeWithKeywordsToTs(program, value.type, value.keywords);
321
+ return value.at === undefined ? typed() : withVisibility(value.at, typed);
322
+ }
323
+ case "whole":
324
+ return propertyValueToTs(program, value.property);
325
+ case "nullable":
326
+ return `${mergedToTs(program, value.inner)} | null`;
327
+ case "union": {
328
+ // The members the validator writes - see `membersToWrite`.
329
+ const members = membersToWrite(program, value);
330
+ const texts = members.map((member) => mergedToTs(program, member));
331
+ /**
332
+ * **A dictionary written twice is written as two objects.** TypeScript reads
333
+ * `Record<string, unknown>` twice as the one type it names, where the validator writes two
334
+ * objects - `z.xor([z.looseObject({}), z.looseObject({})])`, a `@oneOf` of two merged
335
+ * members alike - whose two `z.input` types are two, and the wire assertion told them apart
336
+ * (`TS2344`). Spelled as an object, each is a type of its own, as each validator's is.
337
+ */
338
+ return members
339
+ .map((member, index) => {
340
+ const text = texts[index];
341
+ if (texts.indexOf(text) === texts.lastIndexOf(text))
342
+ return text;
343
+ const previous = spellingDictionaries;
344
+ spellingDictionaries = true;
345
+ try {
346
+ return mergedToTs(program, member);
347
+ }
348
+ finally {
349
+ spellingDictionaries = previous;
350
+ }
351
+ })
352
+ .join(" | ");
353
+ }
354
+ case "all":
355
+ return value.parts.map((part) => `(${mergedToTs(program, part)})`).join(" & ");
356
+ case "array": {
357
+ const list = value.list;
358
+ const element = list === undefined
359
+ ? mergedToTs(program, value.element)
360
+ : atItem(program, list, () => mergedToTs(program, value.element));
361
+ return /[|&\s]/.test(element) ? `(${element})[]` : `${element}[]`;
362
+ }
363
+ case "object":
364
+ return objectToTs(program, value);
365
+ }
366
+ }
367
+ /** A type with keywords on it: the members they leave - see `keptMembersOf`. */
368
+ function typeWithKeywordsToTs(program, type, keywords) {
369
+ const sources = constrainingKeywords(program, keywords);
370
+ if (sources.length === 0)
371
+ return typeToTs(program, type);
372
+ const member = (each) => isTypelessMember(program, each) ? `(${ANY_JSON_TS})` : typeToTs(program, each);
373
+ const members = keptMembersOf(program, type, sources);
374
+ if (members === undefined)
375
+ return member(type);
376
+ return members.length === 0 ? UNREPRESENTABLE : members.map(member).join(" | ");
377
+ }
249
378
  /**
250
379
  * A property line, including its optionality marker.
251
380
  *
@@ -262,7 +391,17 @@ function modelToTs(program, model) {
262
391
  * Shared with the annotation the registry writes on a deferred (getter) property, so the type that
263
392
  * annotation claims is by construction the type this walk renders for the same property.
264
393
  */
265
- export function propertyTypeToTs(program, property) {
394
+ export function propertyTypeToTs(program, property, position = OWN_POSITION) {
395
+ // What the declarations above it leave of the value - see `PropertyPosition`.
396
+ if (position.never || position.refused !== undefined)
397
+ return UNREPRESENTABLE;
398
+ if (position.nullOnly)
399
+ return "null";
400
+ if (position.value !== undefined)
401
+ return mergedToTs(program, position.value);
402
+ return atReading(program, property, () => propertyValueToTs(program, property, position));
403
+ }
404
+ function propertyValueToTs(program, property, position = OWN_POSITION) {
266
405
  /**
267
406
  * **`@encode` changes the wire TYPE, and `encodedTypeOf` is the one rule for when it does.** This
268
407
  * read `getEncode(...).type` directly, so it missed the exception the Zod walk makes: an array
@@ -270,14 +409,57 @@ export function propertyTypeToTs(program, property) {
270
409
  * back into the declared list. So `WireInputs` said `colors: string` for a pipe-delimited query
271
410
  * whose handler is given `string[]` - two walks of one property, one of them knowing a rule.
272
411
  */
273
- const encoded = encodedTypeOf(program, property);
274
- return typeToTs(program, encoded ?? property.type);
412
+ /**
413
+ * **And `encodedValueOf` is the rule for what the value then is**, read exactly as the Zod walk
414
+ * reads it, so `z.input` and this type agree: the declared type where the document keeps it beside
415
+ * the encoded scalar, the scalar where it does not, both where each constrains the other, and
416
+ * `never` where no value is both. A nullable property stays nullable.
417
+ */
418
+ const encoding = encodedValueOf(program, property);
419
+ switch (encoding.kind) {
420
+ case "declared": {
421
+ // A constrained union keeps the members its own keywords allow - see `constrainedMembersOf`.
422
+ const members = constrainedMembersOf(program, property, position);
423
+ const inner = nullableInnerOf(property.type);
424
+ // A `null` a declaration above refuses is left out, as the validator leaves it out.
425
+ if (members === undefined) {
426
+ const declared = position.nonNull && inner !== undefined ? inner : property.type;
427
+ // A property's `@oneOf` makes its inline union exclusive, as the Zod walk reads it.
428
+ return withExclusiveSite(exclusiveSiteOf(program, property, declared), () => typeToTs(program, declared));
429
+ }
430
+ const text = members.length === 0
431
+ ? UNREPRESENTABLE
432
+ : members
433
+ .map((member) => isTypelessMember(program, member) ? `(${ANY_JSON_TS})` : typeToTs(program, member))
434
+ .join(" | ");
435
+ return inner === undefined || position.nonNull ? text : orNull(text, true);
436
+ }
437
+ case "unsatisfiable":
438
+ return UNREPRESENTABLE;
439
+ case "replaced":
440
+ return orNull(typeToTs(program, encoding.scalar), encoding.nullable && !position.nonNull);
441
+ case "both": {
442
+ const member = (each) => replacesFormOf(program, encoding, each)
443
+ ? typeToTs(program, encoding.scalar)
444
+ : typeToTs(program, each);
445
+ const kept = Array.isArray(encoding.kept)
446
+ ? encoding.kept.map(member).join(" | ")
447
+ : member(encoding.kept);
448
+ const value = encodedIntersects(program, property, encoding)
449
+ ? `${typeToTs(program, encoding.scalar)} & (${kept})`
450
+ : kept;
451
+ return orNull(value, encoding.nullable);
452
+ }
453
+ }
454
+ }
455
+ function orNull(text, nullable) {
456
+ return nullable ? `${text} | null` : text;
275
457
  }
276
- export function propertyToTs(program, property, wireName) {
458
+ export function propertyToTs(program, property, wireName, position = OWN_POSITION) {
277
459
  // The WIRE name, resolved exactly as `zod.ts` resolves it - `wire-contract.gen.ts` asserts the
278
460
  // emitted Zod infers this type, so the two must name every property identically or it fails.
279
461
  const key = objectKey(wireName ?? resolveEncodedName(program, property, "application/json"));
280
- const value = propertyTypeToTs(program, property);
462
+ const value = propertyTypeToTs(program, property, position);
281
463
  /**
282
464
  * **A defaulted property used to be REQUIRED here, and that was the OUTPUT type's answer to a
283
465
  * question this file does not ask.**
@@ -333,9 +515,12 @@ export function propertyToTs(program, property, wireName) {
333
515
  * what `propertyToZod` has always consulted. This walk read `property.optional` alone, so an
334
516
  * update contract demanded every field while the validator beside it accepted any subset.
335
517
  */
336
- if (!(property.optional || isOptionalAt(program, property)))
518
+ if (position.required || !(property.optional || isOptionalAt(program, property))) {
337
519
  return `${key}: ${value};`;
338
- return emitsDefault(program, property) ? `${key}?: ${value} | undefined;` : `${key}?: ${value};`;
520
+ }
521
+ return emitsDefault(program, property, position)
522
+ ? `${key}?: ${value} | undefined;`
523
+ : `${key}?: ${value};`;
339
524
  }
340
525
  /** Installed by the registry so a named model is declared once and referenced everywhere else. */
341
526
  let resolveRef = () => undefined;
@@ -363,8 +548,13 @@ export function typeToTsBody(program, type) {
363
548
  switch (type.kind) {
364
549
  case "Scalar":
365
550
  return scalarToTs(program, type);
366
- case "Model":
551
+ case "Model": {
552
+ // Refused as the schema walk refuses it - see `listTemplateInstanceWhy`.
553
+ const why = listTemplateInstanceWhy(program, type);
554
+ if (why !== undefined)
555
+ return refuse(program, type, why, LIST_TEMPLATE_REMEDY);
367
556
  return modelToTs(program, type);
557
+ }
368
558
  case "Enum":
369
559
  return enumToTs(type);
370
560
  case "Union":
@@ -386,9 +576,17 @@ export function typeToTsBody(program, type) {
386
576
  if (type.name === "unknown")
387
577
  return "unknown";
388
578
  if (type.name === "void" || type.name === "never") {
389
- return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never` PROPERTY is dropped to match the document, and `Record<never>` seals the model, so neither of those reaches here.");
579
+ return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never` property or parameter is dropped to match the document, and `Record<never>` seals the model, so none of those reaches here.");
390
580
  }
391
581
  return refuse(program, type, `intrinsic "${type.name}"`, "This emitter has no rule for that intrinsic. Replace it with a declared scalar or model.");
582
+ // `z.input` of the schema walk's `z.tuple([...exactOptional], z.unknown())`.
583
+ case "Tuple": {
584
+ if (inParameterPosition()) {
585
+ return refuse(program, type, TUPLE_PARAMETER.why, TUPLE_PARAMETER.remedy);
586
+ }
587
+ const positions = type.values.map((value) => `(${typeToTs(program, value)})?`);
588
+ return `[${[...positions, "...unknown[]"].join(", ")}]`;
589
+ }
392
590
  default:
393
591
  return refuse(program, type, "no rule for this kind", "Replace it with a model, scalar, enum or union, which are the kinds this emitter walks.");
394
592
  }