@contractkit/plugin-typescript 0.31.2 → 0.33.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 +1 @@
1
- {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAgDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
1
+ {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAsDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.31.2",
3
+ "version": "0.33.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,7 +27,7 @@
27
27
  ".": "./dist/index.js"
28
28
  },
29
29
  "dependencies": {
30
- "@contractkit/core": "0.26.1"
30
+ "@contractkit/core": "0.28.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@repo/config-typescript": "0.1.0",
@@ -23,6 +23,8 @@ import {
23
23
  } from '@contractkit/core';
24
24
  import { escapeJsDocLines } from './ts-render.js';
25
25
  import type { TsRenderTarget } from './ts-render.js';
26
+ import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
27
+ import { renderReviveFunctions, reviveFnName, DECIMAL_COERCE_DECL } from './codegen-revive.js';
26
28
 
27
29
  /**
28
30
  * Maps a ContractKit object mode to its Zod constructor name.
@@ -60,6 +62,13 @@ export interface ContractCodegenContext {
60
62
  * `generateContract`, whose Zod schemas are server-shaped by construction. Default `'client'`.
61
63
  */
62
64
  target?: TsRenderTarget;
65
+ /** Model names that carry a `decimal`, directly or transitively. */
66
+ modelsWithDecimal?: Set<string>;
67
+ /**
68
+ * Emit `reviveX()` hydration functions alongside the schemas. Set only for SDK type files: a
69
+ * server handler receives decimals already parsed by `_ZodDecimal`, so it has nothing to revive.
70
+ */
71
+ emitRevivers?: boolean;
63
72
  }
64
73
 
65
74
  // ─── Public entry point ────────────────────────────────────────────────────
@@ -142,6 +151,7 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
142
151
  const needsBinary = rootNeedsScalar(root, 'binary');
143
152
  const needsDatetime = rootNeedsScalar(root, 'datetime');
144
153
  const needsJson = rootNeedsScalar(root, 'json');
154
+ const needsDecimal = rootNeedsScalar(root, 'decimal');
145
155
  const externalRefs = collectExternalRefs(root);
146
156
  const lines: string[] = [];
147
157
 
@@ -166,9 +176,14 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
166
176
  if (needsDuration) luxonImports.push('Duration');
167
177
  if (needsInterval) luxonImports.push('Interval');
168
178
  if (luxonImports.length > 0) lines.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
179
+ if (needsDecimal) lines.push(DECIMAL_IMPORT);
169
180
  for (const ref of allExternalRefs) {
170
181
  const importPath = resolveImportPath(ref, context);
171
- lines.push(`import { ${ref} } from '${importPath}';`);
182
+ // A cross-file model that carries a decimal contributes its reviver too — the local
183
+ // reviver calls it rather than re-deriving the other file's shape.
184
+ const names =
185
+ context?.emitRevivers && context.modelsWithDecimal?.has(ref) ? `${ref}, ${reviveFnName(ref)}` : ref;
186
+ lines.push(`import { ${names} } from '${importPath}';`);
172
187
  }
173
188
  lines.push('');
174
189
  if (needsBinary) {
@@ -184,21 +199,45 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
184
199
  `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
185
200
  );
186
201
  }
202
+ if (needsDecimal) {
203
+ lines.push(...DECIMAL_PRELUDE_LINES);
204
+ }
187
205
  if (needsJson) {
188
206
  lines.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
189
207
  lines.push(
190
208
  `const _ZodJson: z.ZodType<_JsonValue> = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(_ZodJson), z.record(z.string(), _ZodJson)]));`,
191
209
  );
192
210
  }
193
- if (needsBinary || needsDatetime || needsInterval || needsJson) lines.push('');
211
+ if (needsBinary || needsDatetime || needsInterval || needsDecimal || needsJson) lines.push('');
194
212
 
195
213
  const modelsWithWriteonly = new Set(root.models.filter(m => m.fields.some(f => f.visibility === 'writeonly')).map(m => m.name));
196
214
  const modelMap = new Map(root.models.map(m => [m.name, m]));
197
215
 
216
+ const reviveOpts =
217
+ context?.emitRevivers && context.modelsWithDecimal
218
+ ? { modelsWithDecimal: context.modelsWithDecimal, modelsWithOutput: allModelsWithOutput, modelMap }
219
+ : undefined;
220
+
221
+ const bodyLines: string[] = [];
198
222
  for (const model of topoSortModels(root.models)) {
199
- lines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelsWithWriteonly, modelMap, allModelsWithOutput));
223
+ bodyLines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelsWithWriteonly, modelMap, allModelsWithOutput));
224
+ if (reviveOpts) {
225
+ const revivers = renderReviveFunctions(model, reviveOpts);
226
+ if (revivers.length > 0) {
227
+ bodyLines.push('');
228
+ bodyLines.push(...revivers);
229
+ }
230
+ }
231
+ bodyLines.push('');
232
+ }
233
+
234
+ // Decided from the emitted revivers rather than from a predicate over the AST, so the
235
+ // declaration and its uses cannot drift apart and leave an unused local behind.
236
+ if (bodyLines.some(l => l.includes('__dec('))) {
237
+ lines.push(...DECIMAL_COERCE_DECL);
200
238
  lines.push('');
201
239
  }
240
+ lines.push(...bodyLines);
202
241
 
203
242
  return lines.join('\n');
204
243
  }
@@ -631,6 +670,22 @@ function renderScalar(s: ScalarTypeNode): string {
631
670
  if (s.max !== undefined) inner += `.max(${s.max}n)`;
632
671
  return `z.preprocess((val) => typeof val === 'string' ? BigInt(val.replace(/n$/, '')) : val, ${inner})`;
633
672
  }
673
+ case 'decimal': {
674
+ // Deliberately no output `.transform()`: `isRevalidatable` in codegen-operation treats
675
+ // every scalar as idempotent under re-parse, which `server.validateResponses` relies on.
676
+ // Preprocess passes an already-`Decimal` value straight through, so parse(parse(x)) is
677
+ // stable. Modelling this on `_ZodInterval` — which does transform — would break that.
678
+ const checks: string[] = [];
679
+ if (s.scale !== undefined) checks.push(`v.decimalPlaces() <= ${s.scale}`);
680
+ if (s.min !== undefined) checks.push(`v.gte('${escapeString(String(s.min))}')`);
681
+ if (s.max !== undefined) checks.push(`v.lte('${escapeString(String(s.max))}')`);
682
+ if (checks.length === 0) return '_ZodDecimal';
683
+ const messageParts: string[] = [];
684
+ if (s.scale !== undefined) messageParts.push(`at most ${s.scale} decimal place${s.scale === 1 ? '' : 's'}`);
685
+ if (s.min !== undefined) messageParts.push(`at least ${s.min}`);
686
+ if (s.max !== undefined) messageParts.push(`at most ${s.max}`);
687
+ return `_ZodDecimal.refine((v) => ${checks.join(' && ')}, { message: 'Must be ${escapeString(messageParts.join(', '))}' })`;
688
+ }
634
689
  case 'boolean':
635
690
  return `z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())`;
636
691
  case 'date': {
@@ -3,6 +3,7 @@ import { resolveModifiers, emittedResponses } from '@contractkit/core';
3
3
  import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
4
4
  import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
5
5
  import { quoteKey, escapeSingleQuoted } from './ts-render.js';
6
+ import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
6
7
  import { basename, dirname, relative } from 'node:path';
7
8
 
8
9
  // ─── Options ────────────────────────────────────────────────────────────────
@@ -289,6 +290,9 @@ function scalarHelperLines(body: string): string[] {
289
290
  `const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
290
291
  );
291
292
  }
293
+ if (body.includes('_ZodDecimal')) {
294
+ lines.push(...DECIMAL_PRELUDE_LINES);
295
+ }
292
296
  if (body.includes('_ZodInterval')) {
293
297
  lines.push(
294
298
  `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
@@ -430,6 +434,7 @@ export function generateMcpFile(root: OpRootNode, options: McpCodegenOptions = {
430
434
  if (/\bInterval\b/.test(bodyWithHelpers)) luxon.push('Interval');
431
435
  if (/\bDuration\b/.test(bodyWithHelpers)) luxon.push('Duration');
432
436
  if (luxon.length > 0) imports.push(`import { ${luxon.join(', ')} } from 'luxon';`);
437
+ if (/\bDecimal\b/.test(bodyWithHelpers)) imports.push(DECIMAL_IMPORT);
433
438
 
434
439
  imports.push(`import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';`);
435
440
  imports.push(`import type { McpToolHandler, McpToolHandlerMap, McpToolContext } from '@maroonedsoftware/mcp';`);
@@ -18,6 +18,7 @@ import {
18
18
  modeToWrapper,
19
19
  } from './codegen-contract.js';
20
20
  import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
21
+ import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
21
22
  import { basename, dirname, relative } from 'path';
22
23
 
23
24
  // ─── Content-type helpers ──────────────────────────────────────────────────
@@ -50,7 +51,15 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
50
51
  switch (a.kind) {
51
52
  case 'scalar': {
52
53
  const bb = b as typeof a;
53
- return a.name === bb.name && a.min === bb.min && a.max === bb.max && a.len === bb.len && a.regex === bb.regex && a.format === bb.format;
54
+ return (
55
+ a.name === bb.name &&
56
+ a.min === bb.min &&
57
+ a.max === bb.max &&
58
+ a.len === bb.len &&
59
+ a.scale === bb.scale &&
60
+ a.regex === bb.regex &&
61
+ a.format === bb.format
62
+ );
54
63
  }
55
64
  case 'array': {
56
65
  const bb = b as typeof a;
@@ -132,6 +141,18 @@ export interface OpCodegenOptions {
132
141
  * from the generated router entirely.
133
142
  */
134
143
  includeInternal?: boolean;
144
+ /**
145
+ * Re-parse the service result through its declared response schema before writing `ctx.body`,
146
+ * and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
147
+ * plain interfaces are types, with no runtime schema value to validate against. Default false.
148
+ */
149
+ validateResponses?: boolean;
150
+ /**
151
+ * Set of model names whose schema applies a `format(...)` key transform, directly or through a
152
+ * referenced model. Response bodies touching one are left unvalidated: the service returns the
153
+ * post-transform shape, which the schema itself cannot re-parse.
154
+ */
155
+ modelsWithTransform?: Set<string>;
135
156
  }
136
157
 
137
158
  /**
@@ -185,6 +206,9 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
185
206
  `const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
186
207
  );
187
208
  }
209
+ if (references('_ZodDecimal')) {
210
+ helpers.push(...DECIMAL_PRELUDE_LINES);
211
+ }
188
212
  if (references('_ZodInterval')) {
189
213
  helpers.push(
190
214
  `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
@@ -212,13 +236,20 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
212
236
  body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
213
237
  }
214
238
 
215
- for (const svc of services) {
239
+ // Services and model names come from the AST, which over-approximates two ways: a model with an
240
+ // Input/Output variant contributes its base name even when only the variant is ever annotated,
241
+ // and `collectServices`/`collectTypes` walk every operation including the `internal` ones
242
+ // `includeInternal: false` drops. Filtering through `uses` — the same gate every symbol above
243
+ // goes through — keeps the import list to what the handlers actually reference, so generated
244
+ // code does not trip `noUnusedLocals` in the consuming project.
245
+ for (const svc of services.filter(uses)) {
216
246
  const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
217
247
  body.push(`import { ${svc} } from '${modulePath}';`);
218
248
  }
219
249
 
220
- if (types.length > 0) {
221
- body.push(...generateTypeImports(types, root.file, options));
250
+ const usedTypes = types.filter(uses);
251
+ if (usedTypes.length > 0) {
252
+ body.push(...generateTypeImports(usedTypes, root.file, options));
222
253
  }
223
254
 
224
255
  // luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
@@ -228,6 +259,12 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
228
259
  body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
229
260
  }
230
261
 
262
+ // Same `uses` gate: `Decimal` appears in the `_ZodDecimal` helper and in service-result
263
+ // annotations via `serverTsScalar`, and the helper text is folded into `generated` above.
264
+ if (uses('Decimal')) {
265
+ body.push(DECIMAL_IMPORT);
266
+ }
267
+
231
268
  if (uses('parseAndValidate')) {
232
269
  body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
233
270
  }
@@ -383,9 +420,12 @@ function generateSingleStatusResult(
383
420
  const hasRespHeaders = respHeaders.length > 0;
384
421
  const headersAnnotation = hasRespHeaders ? renderHeadersAnnotation(respHeaders, options.modelsWithOutput) : '';
385
422
 
423
+ let bodySchema: string | undefined;
424
+
386
425
  if (bodies.length === 1) {
387
426
  const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
388
427
  if (prelude) lines.push(` ${prelude}`);
428
+ bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? 'resultType' : undefined);
389
429
  lines.push(` const service = ctx.container.get(${className});`);
390
430
  if (hasRespHeaders) {
391
431
  lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
@@ -393,7 +433,9 @@ function generateSingleStatusResult(
393
433
  lines.push(` const result: ${annotation} = ${call};`);
394
434
  }
395
435
  } else if (bodies.length > 1) {
396
- const { members, preludes } = renderResponseMembers(resp!, options, { includeStatus: false, varPrefix: 'result' });
436
+ const rendered = renderResponseMembers(resp!, options, { includeStatus: false, varPrefix: 'result' });
437
+ const { members, preludes } = rendered;
438
+ bodySchema = rendered.bodySchema;
397
439
  for (const prelude of preludes) lines.push(` ${prelude}`);
398
440
  lines.push(` const service = ctx.container.get(${className});`);
399
441
  lines.push(` const result: ${members.join(' | ')} = ${call};`);
@@ -414,10 +456,10 @@ function generateSingleStatusResult(
414
456
 
415
457
  if (bodies.length === 1) {
416
458
  lines.push(` ctx.type = '${bodies[0]!.contentType}';`);
417
- lines.push(` ctx.body = ${hasRespHeaders ? 'result.body' : 'result'};`);
459
+ lines.push(` ctx.body = ${responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)};`);
418
460
  } else if (bodies.length > 1) {
419
461
  lines.push(` ctx.type = result.contentType;`);
420
- lines.push(` ctx.body = result.body;`);
462
+ lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchema)};`);
421
463
  }
422
464
 
423
465
  return lines;
@@ -433,10 +475,13 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
433
475
  const members: string[] = [];
434
476
  const preludes: string[] = [];
435
477
 
478
+ const bodySchemas = new Map<number, string | undefined>();
479
+
436
480
  for (const resp of emitted) {
437
481
  const rendered = renderResponseMembers(resp, options, { includeStatus: true, varPrefix: `result${resp.statusCode}` });
438
482
  members.push(...rendered.members);
439
483
  preludes.push(...rendered.preludes);
484
+ bodySchemas.set(resp.statusCode, rendered.bodySchema);
440
485
  }
441
486
 
442
487
  for (const prelude of preludes) lines.push(` ${prelude}`);
@@ -452,7 +497,7 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
452
497
  lines.push(...headerSetLines(resp.headers ?? [], ' '));
453
498
  if (resp.bodies.length > 0) {
454
499
  lines.push(` ctx.type = result.contentType;`);
455
- lines.push(` ctx.body = result.body;`);
500
+ lines.push(` ctx.body = ${responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))};`);
456
501
  }
457
502
  lines.push(` break;`);
458
503
  }
@@ -473,7 +518,7 @@ function renderResponseMembers(
473
518
  resp: OpResponseNode,
474
519
  options: OpCodegenOptions,
475
520
  opts: { includeStatus: boolean; varPrefix: string },
476
- ): { members: string[]; preludes: string[] } {
521
+ ): { members: string[]; preludes: string[]; bodySchema?: string } {
477
522
  const bodies = resp.bodies;
478
523
  const headers = resp.headers ?? [];
479
524
  const leading = opts.includeStatus ? [`status: ${resp.statusCode}`] : [];
@@ -488,10 +533,18 @@ function renderResponseMembers(
488
533
  if (uniform) {
489
534
  const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type`);
490
535
  if (prelude) preludes.push(prelude);
536
+ const bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? `${opts.varPrefix}Type` : undefined);
491
537
  const contentType = bodies.map(b => `'${b.contentType}'`).join(' | ');
492
- return { members: [`{ ${[...leading, `contentType: ${contentType}`, `body: ${annotation}`, ...trailing].join('; ')} }`], preludes };
538
+ return {
539
+ members: [`{ ${[...leading, `contentType: ${contentType}`, `body: ${annotation}`, ...trailing].join('; ')} }`],
540
+ preludes,
541
+ bodySchema,
542
+ };
493
543
  }
494
544
 
545
+ // One member per mime: `contentType` and `body` stay correlated, so validating would need a
546
+ // second switch on `result.contentType` nested inside the status switch. Left unvalidated —
547
+ // note the absent `bodySchema` in the return below.
495
548
  const members = bodies.map((b, i) => {
496
549
  const { annotation, prelude } = formatTypeAnnotation(b.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type${i}`);
497
550
  if (prelude) preludes.push(prelude);
@@ -610,6 +663,10 @@ function serverTsScalar(name: ScalarTypeNode['name']): string {
610
663
  return 'number';
611
664
  case 'bigint':
612
665
  return 'bigint';
666
+ case 'decimal':
667
+ // Unlike the date scalars, this matches `renderTsScalar`'s wire view — `_ZodDecimal`
668
+ // has no output transform, so `z.infer` is a `Decimal` on both sides.
669
+ return 'Decimal';
613
670
  case 'boolean':
614
671
  return 'boolean';
615
672
  case 'date':
@@ -660,6 +717,71 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
660
717
  };
661
718
  }
662
719
 
720
+ /**
721
+ * Whether a response body can be soundly re-parsed through the schema `renderType` emits for it.
722
+ *
723
+ * False for anything transitively touching a model with a `format(...)` key transform — the service
724
+ * returns the post-transform value while the schema expects the pre-transform one — and for an
725
+ * intersection outside `renderIntersection`'s `.extend()` fast path, where `.and()` of two strict
726
+ * objects rejects every value because each side sees the other's keys as unrecognized.
727
+ */
728
+ function isRevalidatable(type: ContractTypeNode, modelsWithOutput?: Set<string>, modelsWithTransform?: Set<string>): boolean {
729
+ const rec = (t: ContractTypeNode): boolean => isRevalidatable(t, modelsWithOutput, modelsWithTransform);
730
+ switch (type.kind) {
731
+ case 'ref':
732
+ return !modelsWithOutput?.has(type.name) && !modelsWithTransform?.has(type.name);
733
+ case 'array':
734
+ return rec(type.item);
735
+ case 'tuple':
736
+ return type.items.every(rec);
737
+ case 'record':
738
+ return rec(type.key) && rec(type.value);
739
+ case 'intersection': {
740
+ // Mirrors renderIntersection: a lone member renders as itself, `ref & (ref|object)*`
741
+ // renders as an `.extend()` chain, and anything else falls back to `.and()`.
742
+ const [first, ...rest] = type.members;
743
+ if (!first) return true;
744
+ if (rest.length === 0) return rec(first);
745
+ const usesExtendChain = first.kind === 'ref' && rest.every(m => m.kind === 'ref' || m.kind === 'inlineObject');
746
+ return usesExtendChain && type.members.every(rec);
747
+ }
748
+ case 'union':
749
+ case 'discriminatedUnion':
750
+ return type.members.every(rec);
751
+ case 'inlineObject':
752
+ return type.fields.every(f => rec(f.type));
753
+ case 'lazy':
754
+ return rec(type.inner);
755
+ default:
756
+ // scalar, enum, literal — identity or idempotent under re-parse
757
+ return true;
758
+ }
759
+ }
760
+
761
+ /**
762
+ * The runtime schema a handler validates a response body against, or `undefined` when the body
763
+ * cannot be soundly re-parsed and validation must be skipped.
764
+ *
765
+ * @param preludeVar The schema const {@link formatTypeAnnotation} already emitted for this body
766
+ * (complex types only — `undefined` when it returned no prelude). Reusing it keeps a large object
767
+ * literal from appearing twice in the same handler.
768
+ */
769
+ function responseBodySchema(bodyType: ContractTypeNode, options: OpCodegenOptions, preludeVar: string | undefined): string | undefined {
770
+ if (!options.validateResponses) return undefined;
771
+ if (!isRevalidatable(bodyType, options.modelsWithOutput, options.modelsWithTransform)) return undefined;
772
+ return preludeVar ?? renderType(bodyType);
773
+ }
774
+
775
+ /**
776
+ * The `ctx.body = ...` right-hand side for a response body: the raw result expression, or a
777
+ * `parseAndValidate` of it. The `500` is deliberate — a service returning a shape its own contract
778
+ * rejects is a server fault, not a client one, and `@maroonedsoftware/zod` routes the field-level
779
+ * detail to `internalDetails` (log-only) rather than the response body at 5xx.
780
+ */
781
+ function responseBodyExpr(value: string, schema: string | undefined): string {
782
+ return schema ? `await parseAndValidate(${value}, ${schema}, 500)` : value;
783
+ }
784
+
663
785
  function generateParamValidation(
664
786
  source: ParamSource | undefined,
665
787
  ctxExpr: string,
@@ -12,6 +12,8 @@ import {
12
12
  } from './codegen-contract.js';
13
13
  import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
14
14
  import type { TsRenderTarget } from './ts-render.js';
15
+ import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
16
+ import { renderReviveFunctions, reviveFnName, DECIMAL_COERCE_DECL } from './codegen-revive.js';
15
17
 
16
18
  // ─── Public entry point ────────────────────────────────────────────────────
17
19
 
@@ -44,10 +46,20 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
44
46
  const externalOutputRefs = allModelsWithOutput.size > 0 ? collectExternalOutputRefs(root, allModelsWithOutput) : [];
45
47
  const allExternalRefs = [...new Set([...externalRefs, ...externalInputRefs, ...externalOutputRefs])].sort();
46
48
 
47
- // Type-only imports for external references
49
+ // Not `import type`: `renderTsScalar` maps `decimal` to `Decimal` in this mode too, so the class
50
+ // is a real runtime dependency of any consumer holding one — same position as in
51
+ // `generateContract`, which emits it ahead of the external model refs.
52
+ const needsDecimal = rootNeedsScalar(root, 'decimal') || (context?.emitRevivers && context.modelsWithDecimal ? root.models.some(m => context.modelsWithDecimal!.has(m.name)) : false);
53
+ if (needsDecimal) lines.push(DECIMAL_IMPORT);
54
+
55
+ // Type-only imports for external references. A cross-file model carrying a decimal also
56
+ // contributes its reviver, which is a value and so needs a second, non-type import.
48
57
  for (const ref of allExternalRefs) {
49
58
  const importPath = resolveImportPath(ref, context);
50
59
  lines.push(`import type { ${ref} } from '${importPath}';`);
60
+ if (context?.emitRevivers && context.modelsWithDecimal?.has(ref)) {
61
+ lines.push(`import { ${reviveFnName(ref)} } from '${importPath}';`);
62
+ }
51
63
  }
52
64
  if (allExternalRefs.length > 0) lines.push('');
53
65
 
@@ -62,10 +74,38 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
62
74
 
63
75
  const modelMap = new Map(root.models.map(m => [m.name, m]));
64
76
 
77
+ const reviveOpts =
78
+ context?.emitRevivers && context.modelsWithDecimal
79
+ ? { modelsWithDecimal: context.modelsWithDecimal, modelsWithOutput: allModelsWithOutput, modelMap }
80
+ : undefined;
81
+
82
+ const bodyLines: string[] = [];
65
83
  for (const model of topoSortModels(root.models)) {
66
- lines.push(...generateModel(model, target, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
84
+ bodyLines.push(...generateModel(model, target, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
85
+ if (reviveOpts) {
86
+ const revivers = renderReviveFunctions(model, reviveOpts);
87
+ if (revivers.length > 0) {
88
+ bodyLines.push('');
89
+ bodyLines.push(...revivers);
90
+ }
91
+ }
92
+ bodyLines.push('');
93
+ }
94
+
95
+ // Global decimal.js config belongs in any file holding a `Decimal`: there is no Zod schema in
96
+ // this mode, but `String(value)` and `JSON.stringify` still have to stay out of exponential form.
97
+ if (needsDecimal) {
98
+ lines.push('');
99
+ lines.push(DECIMAL_CONFIG_LINE);
100
+ }
101
+
102
+ // Same rule as in `generateContract`: the helper is emitted only if the revivers actually
103
+ // reference it, so the two cannot drift and trip `noUnusedLocals`.
104
+ if (bodyLines.some(l => l.includes('__dec('))) {
105
+ lines.push(...DECIMAL_COERCE_DECL);
67
106
  lines.push('');
68
107
  }
108
+ lines.push(...bodyLines);
69
109
 
70
110
  return lines.join('\n');
71
111
  }