typespec-http-zod 0.30.0 → 0.32.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 { discriminatedBaseOf, discriminatedSubtypes, effectiveIndexer, emitsDefault, encodeCycleOf, 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,26 +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) {
79
- // Reported once, by the schema walk - see `encodeCycleOf`.
80
- return encodeCycleOf(program, scalar) === undefined
81
- ? scalarToTs(program, encoded)
82
- : UNREPRESENTABLE;
83
- }
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)
87
+ return scalarToTs(program, encoded);
84
88
  let current = scalar;
85
89
  // Walk `extends` so `scalar TrimmedString extends string` resolves to the primitive underneath.
86
90
  while (current !== undefined) {
87
- const mapped = SCALARS[current.name];
91
+ const mapped = program.checker.isStdType(current) ? SCALARS[current.name] : undefined;
88
92
  if (mapped !== undefined)
89
93
  return mapped;
90
94
  current = current.baseScalar;
@@ -105,6 +109,22 @@ function scalarToTs(program, scalar) {
105
109
  */
106
110
  return "unknown";
107
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
+ }
108
128
  function enumToTs(target) {
109
129
  const values = [...target.members.values()].map((member) => member.value ?? member.name);
110
130
  return values.map((value) => literal(value)).join(" | ");
@@ -119,17 +139,39 @@ function unionToTs(program, union) {
119
139
  const [discriminated] = getDiscriminatedUnion(program, union);
120
140
  if (discriminated !== undefined && discriminated.defaultVariant === undefined) {
121
141
  const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
122
- const arms = [...discriminated.variants.entries()].map(([name, variant]) => {
123
- 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)};`;
124
158
  const inner = typeToTs(program, variant);
125
159
  // No injection: with no envelope the variant declares the discriminator itself, or the union
126
160
  // is refused in `zod.ts`. Nothing is added that the document does not publish.
127
- return envelope === "none"
128
- ? inner
129
- : `{ ${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);
130
169
  });
131
170
  return arms.join(" | ");
132
171
  }
172
+ // Refused with the validator, which says why - see `holdsRefusedMember`.
173
+ if (holdsRefusedMember(program, union, isExclusiveHere(program, union)))
174
+ return UNREPRESENTABLE;
133
175
  const variants = [...union.variants.values()].map((variant) => variant.type);
134
176
  if (variants.length === 0) {
135
177
  reportDiagnostic(program, {
@@ -139,7 +181,10 @@ function unionToTs(program, union) {
139
181
  });
140
182
  return UNREPRESENTABLE;
141
183
  }
142
- 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(" | ");
143
188
  }
144
189
  function modelToTs(program, model) {
145
190
  /**
@@ -158,85 +203,79 @@ function modelToTs(program, model) {
158
203
  * of that class it has found. The first was a vocabulary inlined by one walk and referenced by the
159
204
  * other.
160
205
  *
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.
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.
165
209
  */
166
210
  const base = discriminatedBaseOf(program, model);
167
211
  if (base !== undefined) {
168
212
  const arms = discriminatedSubtypes(program, model).map((derived) => typeToTs(program, derived));
169
- if (base.open)
170
- arms.push(modelObjectToTs(program, model, base));
213
+ arms.push(modelObjectToTs(program, model));
171
214
  return arms.join(" | ");
172
215
  }
173
216
  return modelObjectToTs(program, model);
174
217
  }
175
218
  /**
176
219
  * 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.
220
+ * the base, with the `string` discriminator openapi3 adds where the base declares none.
178
221
  */
179
- function modelObjectToTs(program, model, base) {
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;
180
228
  const indexer = effectiveIndexer(model);
181
- const indexerKey = indexer?.key.name;
182
- // Inherited properties included - see `inheritedAndOwnProperties`. Both artefacts describe the
183
- // same model, so a base whose properties reach one and not the other is a contradiction we ship.
184
- const declared = inheritedAndOwnProperties(model);
185
- if (indexerKey === "integer" && indexer !== undefined) {
186
- const element = typeToTs(program, indexer.value);
229
+ if (indexer?.key.name === "integer") {
230
+ const element = atItem(program, model, () => typeToTs(program, indexer.value));
187
231
  // Parenthesised so `A | B` as an element does not become `A | B[]`.
188
232
  return /[|&\s]/.test(element) ? `(${element})[]` : `${element}[]`;
189
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;
190
256
  /**
191
- * **A string indexer only means "dictionary" when the model declares NO properties.**
192
- *
193
- * The same trap `modelToZod` had, and it bites harder here: every permissive response model is
194
- * `{ ...pinned fields; ...Record<unknown> }`, so reading the indexer first would type all 97 of them
195
- * as a bare `Record<string, unknown>` - a type that satisfies any assertion about what the response
196
- * contains, which is precisely the check this file exists to feed.
197
- */
198
- // `Record<never>` states closedness rather than a value type - see `modelToZod`. A model that is
199
- // only that is an object with no declared properties, not a dictionary of an unwalkable type.
200
- if (indexerKey === "string" &&
201
- indexer !== undefined &&
202
- !isNeverType(indexer.value) &&
203
- declared.length === 0) {
204
- return `Record<string, ${typeToTs(program, indexer.value)}>`;
205
- }
206
- /**
207
- * **A `never` property is dropped here too, and this walk is where the refusal actually came
208
- * from.** `modelToZod` filters it because `@typespec/openapi3` omits such a property from the
209
- * document entirely - `model N { value: never; other: string }` publishes as `{other}` with
210
- * `required: ["other"]`. This walk had no property filter at all, so it reached `typeToTs`, hit the
211
- * `Intrinsic` case and reported `unsupported-type` from a type with no source node - which is why
212
- * the diagnostic pointed at `<unknown location>` and why filtering the Zod side alone did not
213
- * silence it.
214
- *
215
- * Two walks over one TypeSpec have to agree about which properties exist, or `wire-contract.gen.ts`
216
- * pairs a validator against a type describing a different shape. That assertion is the only thing
217
- * that catches this emitter disagreeing with itself, and it cannot catch a disagreement that
218
- * aborts the compile first.
219
- */
220
- /**
221
- * **Metadata is filtered out here, as it is on the Zod side, and the two must agree.**
222
- *
223
- * `metadataInfo.isPayloadProperty` is what decides whether a property is in the BODY at this
224
- * position: a `@query` or `@header` property is carried separately, and an `@invisible` one is
225
- * carried nowhere. `modelToZod` has asked since it was written; this walk never did, so a
226
- * contract type listed properties the validator beside it refuses as unknown keys.
227
- *
228
- * **Applied AFTER the dictionary check above, deliberately.** That check reads the UNFILTERED
229
- * list, because a model whose only properties are metadata is still a declared object rather than
230
- * a dictionary, and filtering first would flip it to `Record<string, T>` on one side alone.
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.
231
261
  */
232
- const entries = declared
233
- .filter((property) => isPayloadProperty(program, property))
234
- .filter((property) => !isNeverType(property.type))
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;`);
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);
239
275
  }
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))))}`);
240
279
  /**
241
280
  * **The indexer is deliberately NOT intersected in.**
242
281
  *
@@ -269,6 +308,73 @@ function modelObjectToTs(program, model, base) {
269
308
  */
270
309
  return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
271
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
+ }
272
378
  /**
273
379
  * A property line, including its optionality marker.
274
380
  *
@@ -285,7 +391,17 @@ function modelObjectToTs(program, model, base) {
285
391
  * Shared with the annotation the registry writes on a deferred (getter) property, so the type that
286
392
  * annotation claims is by construction the type this walk renders for the same property.
287
393
  */
288
- 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) {
289
405
  /**
290
406
  * **`@encode` changes the wire TYPE, and `encodedTypeOf` is the one rule for when it does.** This
291
407
  * read `getEncode(...).type` directly, so it missed the exception the Zod walk makes: an array
@@ -293,14 +409,57 @@ export function propertyTypeToTs(program, property) {
293
409
  * back into the declared list. So `WireInputs` said `colors: string` for a pipe-delimited query
294
410
  * whose handler is given `string[]` - two walks of one property, one of them knowing a rule.
295
411
  */
296
- const encoded = encodedTypeOf(program, property);
297
- 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;
298
457
  }
299
- export function propertyToTs(program, property, wireName) {
458
+ export function propertyToTs(program, property, wireName, position = OWN_POSITION) {
300
459
  // The WIRE name, resolved exactly as `zod.ts` resolves it - `wire-contract.gen.ts` asserts the
301
460
  // emitted Zod infers this type, so the two must name every property identically or it fails.
302
461
  const key = objectKey(wireName ?? resolveEncodedName(program, property, "application/json"));
303
- const value = propertyTypeToTs(program, property);
462
+ const value = propertyTypeToTs(program, property, position);
304
463
  /**
305
464
  * **A defaulted property used to be REQUIRED here, and that was the OUTPUT type's answer to a
306
465
  * question this file does not ask.**
@@ -356,9 +515,12 @@ export function propertyToTs(program, property, wireName) {
356
515
  * what `propertyToZod` has always consulted. This walk read `property.optional` alone, so an
357
516
  * update contract demanded every field while the validator beside it accepted any subset.
358
517
  */
359
- if (!(property.optional || isOptionalAt(program, property)))
518
+ if (position.required || !(property.optional || isOptionalAt(program, property))) {
360
519
  return `${key}: ${value};`;
361
- return emitsDefault(program, property) ? `${key}?: ${value} | undefined;` : `${key}?: ${value};`;
520
+ }
521
+ return emitsDefault(program, property, position)
522
+ ? `${key}?: ${value} | undefined;`
523
+ : `${key}?: ${value};`;
362
524
  }
363
525
  /** Installed by the registry so a named model is declared once and referenced everywhere else. */
364
526
  let resolveRef = () => undefined;
@@ -386,8 +548,13 @@ export function typeToTsBody(program, type) {
386
548
  switch (type.kind) {
387
549
  case "Scalar":
388
550
  return scalarToTs(program, type);
389
- 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);
390
556
  return modelToTs(program, type);
557
+ }
391
558
  case "Enum":
392
559
  return enumToTs(type);
393
560
  case "Union":
@@ -409,9 +576,17 @@ export function typeToTsBody(program, type) {
409
576
  if (type.name === "unknown")
410
577
  return "unknown";
411
578
  if (type.name === "void" || type.name === "never") {
412
- 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.");
413
580
  }
414
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
+ }
415
590
  default:
416
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.");
417
592
  }