@workos/oagen-emitters 0.0.1

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.
Files changed (73) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.github/workflows/lint-pr-title.yml +16 -0
  3. package/.github/workflows/lint.yml +21 -0
  4. package/.github/workflows/release-please.yml +28 -0
  5. package/.github/workflows/release.yml +32 -0
  6. package/.husky/commit-msg +1 -0
  7. package/.husky/pre-commit +1 -0
  8. package/.husky/pre-push +1 -0
  9. package/.node-version +1 -0
  10. package/.oxfmtrc.json +10 -0
  11. package/.oxlintrc.json +29 -0
  12. package/.vscode/settings.json +11 -0
  13. package/LICENSE.txt +21 -0
  14. package/README.md +123 -0
  15. package/commitlint.config.ts +1 -0
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.js +2158 -0
  18. package/docs/endpoint-coverage.md +275 -0
  19. package/docs/sdk-architecture/node.md +355 -0
  20. package/oagen.config.ts +51 -0
  21. package/package.json +83 -0
  22. package/renovate.json +26 -0
  23. package/smoke/sdk-dotnet.ts +903 -0
  24. package/smoke/sdk-elixir.ts +771 -0
  25. package/smoke/sdk-go.ts +948 -0
  26. package/smoke/sdk-kotlin.ts +799 -0
  27. package/smoke/sdk-node.ts +516 -0
  28. package/smoke/sdk-php.ts +699 -0
  29. package/smoke/sdk-python.ts +738 -0
  30. package/smoke/sdk-ruby.ts +723 -0
  31. package/smoke/sdk-rust.ts +774 -0
  32. package/src/compat/extractors/dotnet.ts +8 -0
  33. package/src/compat/extractors/elixir.ts +8 -0
  34. package/src/compat/extractors/go.ts +8 -0
  35. package/src/compat/extractors/kotlin.ts +8 -0
  36. package/src/compat/extractors/node.ts +8 -0
  37. package/src/compat/extractors/php.ts +8 -0
  38. package/src/compat/extractors/python.ts +8 -0
  39. package/src/compat/extractors/ruby.ts +8 -0
  40. package/src/compat/extractors/rust.ts +8 -0
  41. package/src/index.ts +1 -0
  42. package/src/node/client.ts +356 -0
  43. package/src/node/common.ts +203 -0
  44. package/src/node/config.ts +70 -0
  45. package/src/node/enums.ts +87 -0
  46. package/src/node/errors.ts +205 -0
  47. package/src/node/fixtures.ts +139 -0
  48. package/src/node/index.ts +57 -0
  49. package/src/node/manifest.ts +23 -0
  50. package/src/node/models.ts +323 -0
  51. package/src/node/naming.ts +96 -0
  52. package/src/node/resources.ts +380 -0
  53. package/src/node/serializers.ts +286 -0
  54. package/src/node/tests.ts +336 -0
  55. package/src/node/type-map.ts +56 -0
  56. package/src/node/utils.ts +164 -0
  57. package/test/compat/extractors/node.test.ts +145 -0
  58. package/test/fixtures/sample-sdk-node/package.json +7 -0
  59. package/test/fixtures/sample-sdk-node/src/client.ts +24 -0
  60. package/test/fixtures/sample-sdk-node/src/index.ts +4 -0
  61. package/test/fixtures/sample-sdk-node/src/models.ts +28 -0
  62. package/test/fixtures/sample-sdk-node/tsconfig.json +13 -0
  63. package/test/node/client.test.ts +165 -0
  64. package/test/node/enums.test.ts +128 -0
  65. package/test/node/errors.test.ts +65 -0
  66. package/test/node/models.test.ts +301 -0
  67. package/test/node/naming.test.ts +212 -0
  68. package/test/node/resources.test.ts +260 -0
  69. package/test/node/serializers.test.ts +206 -0
  70. package/test/node/type-map.test.ts +127 -0
  71. package/tsconfig.json +20 -0
  72. package/tsup.config.ts +8 -0
  73. package/vitest.config.ts +4 -0
@@ -0,0 +1,380 @@
1
+ import type { Service, Operation, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { planOperation, toPascalCase } from '@workos/oagen';
3
+ import type { OperationPlan } from '@workos/oagen';
4
+ import { mapTypeRef } from './type-map.js';
5
+ import {
6
+ fieldName,
7
+ fileName,
8
+ serviceDirName,
9
+ resolveMethodName,
10
+ resolveInterfaceName,
11
+ resolveServiceName,
12
+ buildServiceNameMap,
13
+ wireInterfaceName,
14
+ } from './naming.js';
15
+ import { collectModelRefs, assignModelsToServices, docComment } from './utils.js';
16
+
17
+ export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
18
+ if (services.length === 0) return [];
19
+ return services.map((service) => generateResourceClass(service, ctx));
20
+ }
21
+
22
+ function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
23
+ const resolvedName = resolveServiceName(service, ctx);
24
+ const serviceDir = serviceDirName(resolvedName);
25
+ const serviceClass = resolvedName;
26
+ const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
27
+
28
+ const plans = service.operations.map((op) => ({
29
+ op,
30
+ plan: planOperation(op),
31
+ method: resolveMethodName(op, service, ctx),
32
+ }));
33
+
34
+ const hasPaginated = plans.some((p) => p.plan.isPaginated);
35
+
36
+ // Collect models for imports
37
+ const responseModels = new Set<string>();
38
+ const requestModels = new Set<string>();
39
+ for (const { op, plan } of plans) {
40
+ if (plan.responseModelName) responseModels.add(plan.responseModelName);
41
+ if (op.requestBody) {
42
+ for (const name of collectModelRefs(op.requestBody)) {
43
+ requestModels.add(name);
44
+ }
45
+ }
46
+ }
47
+ const allModels = new Set([...responseModels, ...requestModels]);
48
+
49
+ const lines: string[] = [];
50
+
51
+ // Imports
52
+ lines.push("import type { WorkOS } from '../workos';");
53
+ if (hasPaginated) {
54
+ lines.push("import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';");
55
+ lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
56
+ lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
57
+ }
58
+
59
+ // Check if any operation is an idempotent POST
60
+ const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
61
+ if (hasIdempotentPost) {
62
+ lines.push("import type { PostOptions } from '../common/interfaces/post-options.interface';");
63
+ }
64
+
65
+ // Compute model-to-service mapping for correct cross-service import paths
66
+ const modelToService = assignModelsToServices(ctx.spec.models, ctx.spec.services);
67
+ const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
68
+ const resolveDir = (irService: string | undefined) =>
69
+ irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
70
+
71
+ for (const name of allModels) {
72
+ const resolved = resolveInterfaceName(name, ctx);
73
+ const modelDir = modelToService.get(name);
74
+ const modelServiceDir = resolveDir(modelDir);
75
+ const relPath =
76
+ modelServiceDir === serviceDir
77
+ ? `./interfaces/${fileName(name)}.interface`
78
+ : `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
79
+ lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
80
+ }
81
+
82
+ for (const name of responseModels) {
83
+ const resolved = resolveInterfaceName(name, ctx);
84
+ const modelDir = modelToService.get(name);
85
+ const modelServiceDir = resolveDir(modelDir);
86
+ const relPath =
87
+ modelServiceDir === serviceDir
88
+ ? `./serializers/${fileName(name)}.serializer`
89
+ : `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
90
+ lines.push(`import { deserialize${resolved} } from '${relPath}';`);
91
+ }
92
+
93
+ for (const name of requestModels) {
94
+ const resolved = resolveInterfaceName(name, ctx);
95
+ const modelDir = modelToService.get(name);
96
+ const modelServiceDir = resolveDir(modelDir);
97
+ const relPath =
98
+ modelServiceDir === serviceDir
99
+ ? `./serializers/${fileName(name)}.serializer`
100
+ : `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
101
+ lines.push(`import { serialize${resolved} } from '${relPath}';`);
102
+ }
103
+
104
+ lines.push('');
105
+
106
+ // List options interfaces for paginated operations with extra query params
107
+ for (const { op, plan, method } of plans) {
108
+ if (plan.isPaginated) {
109
+ const extraParams = op.queryParams.filter((p) => !['limit', 'before', 'after', 'order'].includes(p.name));
110
+ if (extraParams.length > 0) {
111
+ const optionsName = toPascalCase(method) + 'Options';
112
+ lines.push(`export interface ${optionsName} extends PaginationOptions {`);
113
+ for (const param of extraParams) {
114
+ const opt = !param.required ? '?' : '';
115
+ if (param.description || param.deprecated) {
116
+ const parts: string[] = [];
117
+ if (param.description) parts.push(param.description);
118
+ if (param.deprecated) parts.push('@deprecated');
119
+ lines.push(...docComment(parts.join('\n'), 2));
120
+ }
121
+ lines.push(` ${fieldName(param.name)}${opt}: ${mapTypeRef(param.type)};`);
122
+ }
123
+ lines.push('}');
124
+ lines.push('');
125
+ }
126
+ }
127
+ }
128
+
129
+ // Resource class
130
+ if (service.description) {
131
+ lines.push(...docComment(service.description));
132
+ }
133
+ lines.push(`export class ${serviceClass} {`);
134
+ lines.push(' constructor(private readonly workos: WorkOS) {}');
135
+
136
+ for (const { op, plan, method } of plans) {
137
+ lines.push('');
138
+ lines.push(...renderMethod(op, plan, method, service, ctx));
139
+ }
140
+
141
+ lines.push('}');
142
+
143
+ return { path: resourcePath, content: lines.join('\n'), skipIfExists: true };
144
+ }
145
+
146
+ function renderMethod(
147
+ op: Operation,
148
+ plan: OperationPlan,
149
+ method: string,
150
+ service: Service,
151
+ ctx: EmitterContext,
152
+ ): string[] {
153
+ const lines: string[] = [];
154
+ const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
155
+
156
+ // Path interpolation: replace {param} with ${param}
157
+ const interpolatedPath = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
158
+ const usesTemplate = interpolatedPath.includes('${');
159
+ const pathStr = usesTemplate ? `\`${interpolatedPath}\`` : `'${op.path}'`;
160
+
161
+ const docParts: string[] = [];
162
+ if (op.description) docParts.push(op.description);
163
+ for (const param of op.pathParams) {
164
+ if (param.description) {
165
+ docParts.push(`@param ${fieldName(param.name)} - ${param.description}`);
166
+ }
167
+ }
168
+ if (op.deprecated) docParts.push('@deprecated');
169
+ for (const err of op.errors) {
170
+ const exceptionName = statusToExceptionName(err.statusCode);
171
+ if (exceptionName) {
172
+ docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
173
+ }
174
+ }
175
+
176
+ if (docParts.length > 0) {
177
+ // Flatten all parts, splitting multiline descriptions into individual lines
178
+ const allLines: string[] = [];
179
+ for (const part of docParts) {
180
+ for (const line of part.split('\n')) {
181
+ allLines.push(line);
182
+ }
183
+ }
184
+ if (allLines.length === 1) {
185
+ lines.push(` /** ${allLines[0]} */`);
186
+ } else {
187
+ lines.push(' /**');
188
+ for (const line of allLines) {
189
+ lines.push(line === '' ? ' *' : ` * ${line}`);
190
+ }
191
+ lines.push(' */');
192
+ }
193
+ }
194
+
195
+ if (plan.isPaginated) {
196
+ if (!responseModel) {
197
+ console.warn(
198
+ `[oagen] Warning: Skipping paginated method "${method}" (${op.httpMethod.toUpperCase()} ${op.path}) — response has no named model. Ensure the spec uses a $ref for paginated item types.`,
199
+ );
200
+ return lines;
201
+ }
202
+ renderPaginatedMethod(lines, op, plan, method, responseModel);
203
+ } else if (plan.isDelete) {
204
+ renderDeleteMethod(lines, op, plan, method, pathStr);
205
+ } else if (plan.hasBody && responseModel) {
206
+ renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx);
207
+ } else if (responseModel) {
208
+ renderGetMethod(lines, op, plan, method, responseModel, pathStr);
209
+ } else {
210
+ renderVoidMethod(lines, op, plan, method, pathStr);
211
+ }
212
+
213
+ return lines;
214
+ }
215
+
216
+ function renderPaginatedMethod(
217
+ lines: string[],
218
+ op: Operation,
219
+ plan: OperationPlan,
220
+ method: string,
221
+ itemType: string,
222
+ ): void {
223
+ const extraParams = op.queryParams.filter((p) => !['limit', 'before', 'after', 'order'].includes(p.name));
224
+ const optionsType = extraParams.length > 0 ? toPascalCase(method) + 'Options' : 'PaginationOptions';
225
+
226
+ const pathStr = buildPathStr(op);
227
+
228
+ lines.push(` async ${method}(options?: ${optionsType}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
229
+ lines.push(' return new AutoPaginatable(');
230
+ lines.push(` await fetchAndDeserialize<${wireInterfaceName(itemType)}, ${itemType}>(`);
231
+ lines.push(' this.workos,');
232
+ lines.push(` ${pathStr},`);
233
+ lines.push(` deserialize${itemType},`);
234
+ lines.push(' options,');
235
+ lines.push(' ),');
236
+ lines.push(' (params) =>');
237
+ lines.push(` fetchAndDeserialize<${wireInterfaceName(itemType)}, ${itemType}>(`);
238
+ lines.push(' this.workos,');
239
+ lines.push(` ${pathStr},`);
240
+ lines.push(` deserialize${itemType},`);
241
+ lines.push(' params,');
242
+ lines.push(' ),');
243
+ lines.push(' options,');
244
+ lines.push(' );');
245
+ lines.push(' }');
246
+ }
247
+
248
+ function renderDeleteMethod(
249
+ lines: string[],
250
+ op: Operation,
251
+ plan: OperationPlan,
252
+ method: string,
253
+ pathStr: string,
254
+ ): void {
255
+ const params = buildPathParams(op);
256
+ lines.push(` async ${method}(${params}): Promise<void> {`);
257
+ lines.push(` await this.workos.delete(${pathStr});`);
258
+ lines.push(' }');
259
+ }
260
+
261
+ function renderBodyMethod(
262
+ lines: string[],
263
+ op: Operation,
264
+ plan: OperationPlan,
265
+ method: string,
266
+ responseModel: string,
267
+ pathStr: string,
268
+ ctx: EmitterContext,
269
+ ): void {
270
+ const requestBodyModel = extractRequestBodyModelName(op);
271
+ const requestType = requestBodyModel ? resolveInterfaceName(requestBodyModel, ctx) : 'any';
272
+
273
+ const paramParts: string[] = [];
274
+
275
+ // Always pass path params as individual parameters (matches existing SDK pattern)
276
+ for (const param of op.pathParams) {
277
+ paramParts.push(`${fieldName(param.name)}: ${mapTypeRef(param.type)}`);
278
+ }
279
+
280
+ paramParts.push(`payload: ${requestType}`);
281
+
282
+ if (plan.isIdempotentPost) {
283
+ paramParts.push('requestOptions: PostOptions = {}');
284
+ }
285
+
286
+ const paramsStr = paramParts.join(', ');
287
+ const bodyExpr = requestBodyModel && requestType !== 'any' ? `serialize${requestType}(payload)` : 'payload';
288
+
289
+ lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
290
+ if (plan.isIdempotentPost) {
291
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
292
+ lines.push(` ${pathStr},`);
293
+ lines.push(` ${bodyExpr},`);
294
+ lines.push(' requestOptions,');
295
+ lines.push(' );');
296
+ } else {
297
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
298
+ lines.push(` ${pathStr},`);
299
+ lines.push(` ${bodyExpr},`);
300
+ lines.push(' );');
301
+ }
302
+ lines.push(` return deserialize${responseModel}(data);`);
303
+ lines.push(' }');
304
+ }
305
+
306
+ function renderGetMethod(
307
+ lines: string[],
308
+ op: Operation,
309
+ plan: OperationPlan,
310
+ method: string,
311
+ responseModel: string,
312
+ pathStr: string,
313
+ ): void {
314
+ const params = buildPathParams(op);
315
+ const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
316
+
317
+ const allParams = hasQuery
318
+ ? params
319
+ ? `${params}, options?: Record<string, any>`
320
+ : 'options?: Record<string, any>'
321
+ : params;
322
+
323
+ lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
324
+ if (hasQuery) {
325
+ lines.push(
326
+ ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
327
+ );
328
+ lines.push(' query: options,');
329
+ lines.push(' });');
330
+ } else {
331
+ lines.push(
332
+ ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
333
+ );
334
+ }
335
+ lines.push(` return deserialize${responseModel}(data);`);
336
+ lines.push(' }');
337
+ }
338
+
339
+ function renderVoidMethod(lines: string[], op: Operation, plan: OperationPlan, method: string, pathStr: string): void {
340
+ const params = buildPathParams(op);
341
+ const allParams = plan.hasBody ? (params ? `${params}, payload: any` : 'payload: any') : params;
342
+
343
+ lines.push(` async ${method}(${allParams}): Promise<void> {`);
344
+ if (plan.hasBody) {
345
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, payload);`);
346
+ } else {
347
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr});`);
348
+ }
349
+ lines.push(' }');
350
+ }
351
+
352
+ function buildPathStr(op: Operation): string {
353
+ const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
354
+ return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
355
+ }
356
+
357
+ function buildPathParams(op: Operation): string {
358
+ return op.pathParams.map((p) => `${fieldName(p.name)}: ${mapTypeRef(p.type)}`).join(', ');
359
+ }
360
+
361
+ function extractRequestBodyModelName(op: Operation): string | null {
362
+ if (!op.requestBody) return null;
363
+ if (op.requestBody.kind === 'model') return op.requestBody.name;
364
+ return null;
365
+ }
366
+
367
+ const STATUS_TO_EXCEPTION: Record<number, string> = {
368
+ 400: 'BadRequestException',
369
+ 401: 'UnauthorizedException',
370
+ 404: 'NotFoundException',
371
+ 409: 'ConflictException',
372
+ 422: 'UnprocessableEntityException',
373
+ 429: 'RateLimitExceededException',
374
+ };
375
+
376
+ function statusToExceptionName(statusCode: number): string | null {
377
+ if (STATUS_TO_EXCEPTION[statusCode]) return STATUS_TO_EXCEPTION[statusCode];
378
+ if (statusCode >= 500) return 'GenericServerException';
379
+ return null;
380
+ }
@@ -0,0 +1,286 @@
1
+ import type { Model, EmitterContext, GeneratedFile, TypeRef, UnionType } from '@workos/oagen';
2
+ import {
3
+ fieldName,
4
+ wireFieldName,
5
+ fileName,
6
+ serviceDirName,
7
+ resolveInterfaceName,
8
+ buildServiceNameMap,
9
+ wireInterfaceName,
10
+ } from './naming.js';
11
+ import { assignModelsToServices, relativeImport } from './utils.js';
12
+
13
+ export function generateSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
14
+ if (models.length === 0) return [];
15
+
16
+ const modelToService = assignModelsToServices(models, ctx.spec.services);
17
+ const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
18
+ const resolveDir = (irService: string | undefined) =>
19
+ irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
20
+ const files: GeneratedFile[] = [];
21
+
22
+ for (const model of models) {
23
+ const service = modelToService.get(model.name);
24
+ const dirName = resolveDir(service);
25
+ const domainName = resolveInterfaceName(model.name, ctx);
26
+ const responseName = wireInterfaceName(domainName);
27
+ const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
28
+
29
+ // Find nested model refs that need their own serializer imports.
30
+ // Only collect models that will actually be called in serialize/deserialize expressions
31
+ // (direct model refs, array-of-model items, nullable-wrapped models, single-model-variant unions).
32
+ const nestedModelRefs = new Set<string>();
33
+ for (const field of model.fields) {
34
+ for (const ref of collectSerializedModelRefs(field.type)) {
35
+ if (ref !== model.name) nestedModelRefs.add(ref);
36
+ }
37
+ }
38
+
39
+ const lines: string[] = [];
40
+
41
+ // Import model interfaces
42
+ const interfacePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
43
+ lines.push(
44
+ `import type { ${domainName}, ${responseName} } from '${relativeImport(serializerPath, interfacePath)}';`,
45
+ );
46
+
47
+ // Import nested model deserializers/serializers
48
+ for (const dep of nestedModelRefs) {
49
+ const depService = modelToService.get(dep);
50
+ const depDir = resolveDir(depService);
51
+ const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
52
+ const depName = resolveInterfaceName(dep, ctx);
53
+ const imports = [`deserialize${depName}`, `serialize${depName}`];
54
+ lines.push(`import { ${imports.join(', ')} } from '${relativeImport(serializerPath, depSerializerPath)}';`);
55
+ }
56
+ lines.push('');
57
+
58
+ // Deserialize function (wire → domain) — deduplicate by camelCase name
59
+ const seenDeserFields = new Set<string>();
60
+ lines.push(`export const deserialize${domainName} = (`);
61
+ lines.push(` response: ${responseName},`);
62
+ lines.push(`): ${domainName} => ({`);
63
+ for (const field of model.fields) {
64
+ const domain = fieldName(field.name);
65
+ if (seenDeserFields.has(domain)) continue;
66
+ seenDeserFields.add(domain);
67
+ const wire = wireFieldName(field.name);
68
+ const wireAccess = `response.${wire}`;
69
+ const expr = deserializeExpression(field.type, wireAccess, ctx);
70
+ // If the field is optional and the expression involves a function call,
71
+ // wrap with a null check to avoid passing undefined to the deserializer
72
+ if (!field.required && expr !== wireAccess && needsNullGuard(field.type)) {
73
+ lines.push(` ${domain}: ${wireAccess} != null ? ${expr} : undefined,`);
74
+ } else if (field.required && expr === wireAccess) {
75
+ // Required field with direct assignment — add fallback for cases where
76
+ // the response interface makes the field optional (baseline override)
77
+ const fallback = defaultForType(field.type);
78
+ if (fallback) {
79
+ lines.push(` ${domain}: ${expr} ?? ${fallback},`);
80
+ } else {
81
+ lines.push(` ${domain}: ${expr},`);
82
+ }
83
+ } else {
84
+ lines.push(` ${domain}: ${expr},`);
85
+ }
86
+ }
87
+ lines.push('});');
88
+
89
+ // Serialize function (domain → wire)
90
+ lines.push('');
91
+ lines.push(`export const serialize${domainName} = (`);
92
+ lines.push(` model: ${domainName},`);
93
+ lines.push(`): ${responseName} => ({`);
94
+ const seenSerFields = new Set<string>();
95
+ for (const field of model.fields) {
96
+ const wire = wireFieldName(field.name);
97
+ if (seenSerFields.has(wire)) continue;
98
+ seenSerFields.add(wire);
99
+ const domain = fieldName(field.name);
100
+ const domainAccess = `model.${domain}`;
101
+ const expr = serializeExpression(field.type, domainAccess, ctx);
102
+ // If the field is optional and the expression involves a function call,
103
+ // wrap with a null check to avoid passing undefined to the serializer
104
+ if (!field.required && expr !== domainAccess && needsNullGuard(field.type)) {
105
+ lines.push(` ${wire}: ${domainAccess} != null ? ${expr} : undefined,`);
106
+ } else {
107
+ lines.push(` ${wire}: ${expr},`);
108
+ }
109
+ }
110
+ lines.push('});');
111
+
112
+ files.push({
113
+ path: serializerPath,
114
+ content: lines.join('\n'),
115
+ skipIfExists: true,
116
+ });
117
+ }
118
+
119
+ return files;
120
+ }
121
+
122
+ /**
123
+ * Collect model names that will actually be called in serialize/deserialize expressions.
124
+ * Unlike collectModelRefs (which walks all union variants), this only includes models
125
+ * that the expression functions will actually invoke a serializer/deserializer for.
126
+ */
127
+ function collectSerializedModelRefs(ref: TypeRef): string[] {
128
+ switch (ref.kind) {
129
+ case 'model':
130
+ return [ref.name];
131
+ case 'array':
132
+ if (ref.items.kind === 'model') return [ref.items.name];
133
+ return collectSerializedModelRefs(ref.items);
134
+ case 'nullable':
135
+ return collectSerializedModelRefs(ref.inner);
136
+ case 'union': {
137
+ const models = uniqueModelVariants(ref);
138
+ // Only if exactly one unique model variant — that's when we call its serializer
139
+ if (models.length === 1) return models;
140
+ return [];
141
+ }
142
+ case 'map':
143
+ case 'primitive':
144
+ case 'literal':
145
+ case 'enum':
146
+ return [];
147
+ }
148
+ }
149
+
150
+ function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterContext): string {
151
+ switch (ref.kind) {
152
+ case 'primitive':
153
+ case 'literal':
154
+ case 'enum':
155
+ return wireExpr;
156
+ case 'model': {
157
+ const name = resolveInterfaceName(ref.name, ctx);
158
+ return `deserialize${name}(${wireExpr})`;
159
+ }
160
+ case 'array':
161
+ if (ref.items.kind === 'model') {
162
+ const name = resolveInterfaceName(ref.items.name, ctx);
163
+ return `${wireExpr}.map(deserialize${name})`;
164
+ }
165
+ return wireExpr;
166
+ case 'nullable': {
167
+ const innerExpr = deserializeExpression(ref.inner, wireExpr, ctx);
168
+ // If the inner type involves a function call (model or array-of-model),
169
+ // wrap with a null check to avoid passing null to the deserializer
170
+ if (innerExpr !== wireExpr) {
171
+ return `${wireExpr} != null ? ${innerExpr} : null`;
172
+ }
173
+ return `${wireExpr} ?? null`;
174
+ }
175
+ case 'union': {
176
+ // If the union has exactly one unique model variant, deserialize using that model's deserializer
177
+ const deserModelVariants = uniqueModelVariants(ref);
178
+ if (deserModelVariants.length === 1) {
179
+ const name = resolveInterfaceName(deserModelVariants[0], ctx);
180
+ return `deserialize${name}(${wireExpr})`;
181
+ }
182
+ return wireExpr;
183
+ }
184
+ case 'map':
185
+ return wireExpr;
186
+ }
187
+ }
188
+
189
+ function serializeExpression(ref: TypeRef, domainExpr: string, ctx: EmitterContext): string {
190
+ switch (ref.kind) {
191
+ case 'primitive':
192
+ case 'literal':
193
+ case 'enum':
194
+ return domainExpr;
195
+ case 'model': {
196
+ const name = resolveInterfaceName(ref.name, ctx);
197
+ return `serialize${name}(${domainExpr})`;
198
+ }
199
+ case 'array':
200
+ if (ref.items.kind === 'model') {
201
+ const name = resolveInterfaceName(ref.items.name, ctx);
202
+ return `${domainExpr}.map(serialize${name})`;
203
+ }
204
+ return domainExpr;
205
+ case 'nullable': {
206
+ const innerExpr = serializeExpression(ref.inner, domainExpr, ctx);
207
+ // If the inner type involves a function call (model or array-of-model),
208
+ // wrap with a null check to avoid passing null to the serializer
209
+ if (innerExpr !== domainExpr) {
210
+ return `${domainExpr} != null ? ${innerExpr} : null`;
211
+ }
212
+ return domainExpr;
213
+ }
214
+ case 'union': {
215
+ // If the union has exactly one unique model variant, serialize using that model's serializer
216
+ const serModelVariants = uniqueModelVariants(ref);
217
+ if (serModelVariants.length === 1) {
218
+ const name = resolveInterfaceName(serModelVariants[0], ctx);
219
+ return `serialize${name}(${domainExpr})`;
220
+ }
221
+ return domainExpr;
222
+ }
223
+ case 'map':
224
+ return domainExpr;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Extract unique model names from a union's variants.
230
+ * Used to determine if a union can be deserialized/serialized as a single model.
231
+ */
232
+ function uniqueModelVariants(ref: UnionType): string[] {
233
+ const modelNames = new Set<string>();
234
+ for (const v of ref.variants) {
235
+ if (v.kind === 'model') modelNames.add(v.name);
236
+ }
237
+ return [...modelNames];
238
+ }
239
+
240
+ /**
241
+ * Check whether a TypeRef involves a model reference that would produce
242
+ * a function call in serialization/deserialization. Used to determine
243
+ * whether optional fields need a null guard wrapper.
244
+ */
245
+ function needsNullGuard(ref: TypeRef): boolean {
246
+ switch (ref.kind) {
247
+ case 'model':
248
+ return true;
249
+ case 'array':
250
+ return ref.items.kind === 'model';
251
+ case 'nullable':
252
+ return needsNullGuard(ref.inner);
253
+ case 'union':
254
+ return uniqueModelVariants(ref).length === 1;
255
+ default:
256
+ return false;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Return a TypeScript default value expression for a type, used as a null
262
+ * coalesce fallback when a required domain field may be optional in the
263
+ * response interface (baseline override mismatch).
264
+ */
265
+ function defaultForType(ref: TypeRef): string | null {
266
+ switch (ref.kind) {
267
+ case 'map':
268
+ return '{}';
269
+ case 'primitive':
270
+ switch (ref.type) {
271
+ case 'boolean':
272
+ return 'false';
273
+ case 'string':
274
+ return "''";
275
+ case 'integer':
276
+ case 'number':
277
+ return '0';
278
+ default:
279
+ return null;
280
+ }
281
+ case 'array':
282
+ return '[]';
283
+ default:
284
+ return null;
285
+ }
286
+ }