@workos/oagen-emitters 0.0.1 → 0.2.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.
Files changed (41) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.prettierignore +1 -0
  6. package/.release-please-manifest.json +3 -0
  7. package/.vscode/settings.json +3 -0
  8. package/CHANGELOG.md +54 -0
  9. package/README.md +2 -2
  10. package/dist/index.d.mts +7 -0
  11. package/dist/index.d.mts.map +1 -0
  12. package/dist/index.mjs +3522 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/package.json +14 -18
  15. package/release-please-config.json +11 -0
  16. package/src/node/client.ts +437 -204
  17. package/src/node/common.ts +74 -4
  18. package/src/node/config.ts +1 -0
  19. package/src/node/enums.ts +50 -6
  20. package/src/node/errors.ts +78 -3
  21. package/src/node/fixtures.ts +84 -15
  22. package/src/node/index.ts +2 -2
  23. package/src/node/manifest.ts +4 -2
  24. package/src/node/models.ts +195 -79
  25. package/src/node/naming.ts +16 -1
  26. package/src/node/resources.ts +721 -106
  27. package/src/node/serializers.ts +510 -52
  28. package/src/node/tests.ts +621 -105
  29. package/src/node/type-map.ts +89 -11
  30. package/src/node/utils.ts +377 -114
  31. package/test/node/client.test.ts +979 -15
  32. package/test/node/enums.test.ts +0 -1
  33. package/test/node/errors.test.ts +4 -21
  34. package/test/node/models.test.ts +409 -2
  35. package/test/node/naming.test.ts +0 -3
  36. package/test/node/resources.test.ts +964 -7
  37. package/test/node/serializers.test.ts +212 -3
  38. package/tsconfig.json +2 -3
  39. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  40. package/dist/index.d.ts +0 -5
  41. package/dist/index.js +0 -2158
@@ -1,30 +1,157 @@
1
- import type { Model, EmitterContext, GeneratedFile, TypeRef, UnionType } from '@workos/oagen';
1
+ import type { Model, EmitterContext, GeneratedFile, TypeRef, UnionType, PrimitiveType } from '@workos/oagen';
2
+ import { mapTypeRef as tsMapTypeRef } from './type-map.js';
3
+ import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
2
4
  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';
5
+ relativeImport,
6
+ pruneUnusedImports,
7
+ detectStringDateConvention,
8
+ buildKnownTypeNames,
9
+ isBaselineGeneric,
10
+ createServiceDirResolver,
11
+ isListMetadataModel,
12
+ isListWrapperModel,
13
+ buildDeduplicationMap,
14
+ } from './utils.js';
15
+
16
+ /**
17
+ * Render generic type parameter declarations for a model.
18
+ * E.g., `<CustomAttributesType = Record<string, unknown>>`.
19
+ * Returns empty string for non-generic models.
20
+ */
21
+ function renderSerializerTypeParams(model: Model, ctx?: EmitterContext): { decl: string; usage: string } {
22
+ if (model.typeParams?.length) {
23
+ const params = model.typeParams.map((tp) => {
24
+ const def = tp.default ? ` = ${tsMapTypeRef(tp.default)}` : '';
25
+ return `${tp.name}${def}`;
26
+ });
27
+ const names = model.typeParams.map((tp) => tp.name);
28
+ return { decl: `<${params.join(', ')}>`, usage: `<${names.join(', ')}>` };
29
+ }
30
+ // Fallback: check if the baseline interface is generic (hand-written generics
31
+ // not captured in the IR). Only apply if the baseline file path matches the
32
+ // generated path — meaning the existing generic file will be preserved via
33
+ // skipIfExists. If paths differ, the interface is newly generated and non-generic.
34
+ if (ctx?.apiSurface?.interfaces) {
35
+ const domainName = resolveInterfaceName(model.name, ctx);
36
+ const baseline = ctx.apiSurface.interfaces[domainName];
37
+ if (baseline?.fields) {
38
+ const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
39
+ const { modelToService, resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
40
+ const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
41
+ const pathMatches = !baselineSourceFile || baselineSourceFile === generatedPath;
42
+ const knownNames = buildKnownTypeNames(ctx.spec.models, ctx);
43
+ if (pathMatches && isBaselineGeneric(baseline.fields, knownNames)) {
44
+ return {
45
+ decl: '<GenericType extends Record<string, unknown> = Record<string, unknown>>',
46
+ usage: '<GenericType>',
47
+ };
48
+ }
49
+ }
50
+ }
51
+ return { decl: '', usage: '' };
52
+ }
12
53
 
13
54
  export function generateSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
14
55
  if (models.length === 0) return [];
15
56
 
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';
57
+ const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
58
+ const useStringDates = detectStringDateConvention(models, ctx);
20
59
  const files: GeneratedFile[] = [];
60
+ const dedup = buildDeduplicationMap(models, ctx);
61
+ // Track model names whose serialize function was skipped due to baseline incompatibility.
62
+ // Dependent serializers that import a skipped serialize function must also skip.
63
+ const skippedSerializeModels = new Set<string>();
21
64
 
22
65
  for (const model of models) {
66
+ // Fix #5: Skip per-domain ListMetadata serializers — the shared deserializeListMetadata covers these
67
+ if (isListMetadataModel(model)) continue;
68
+
69
+ // Fix #7: Skip per-domain list wrapper serializers — the shared deserializeList covers these
70
+ if (isListWrapperModel(model)) continue;
71
+
72
+ // Deduplication: for structurally identical models, re-export the canonical serializer
73
+ const canonicalName = dedup.get(model.name);
74
+ if (canonicalName) {
75
+ const domainName = resolveInterfaceName(model.name, ctx);
76
+ const canonDomainName = resolveInterfaceName(canonicalName, ctx);
77
+ const service = modelToService.get(model.name);
78
+ const dirName = resolveDir(service);
79
+ const canonService = modelToService.get(canonicalName);
80
+ const canonDir = resolveDir(canonService);
81
+ const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
82
+ const canonSerializerPath = `src/${canonDir}/serializers/${fileName(canonicalName)}.serializer.ts`;
83
+ const rel = relativeImport(serializerPath, canonSerializerPath);
84
+ const aliasLines = [
85
+ `export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`,
86
+ ];
87
+ files.push({
88
+ path: serializerPath,
89
+ content: aliasLines.join('\n'),
90
+ });
91
+ continue;
92
+ }
93
+
23
94
  const service = modelToService.get(model.name);
24
95
  const dirName = resolveDir(service);
25
96
  const domainName = resolveInterfaceName(model.name, ctx);
26
97
  const responseName = wireInterfaceName(domainName);
27
98
  const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
99
+ const typeParams = renderSerializerTypeParams(model, ctx);
100
+ const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
101
+
102
+ // Build a set of field names where format conversion (new Date / BigInt) should
103
+ // be skipped. When the SDK-wide convention is string dates, ALL date-time fields
104
+ // in ALL models skip conversion — not just those with a baseline interface.
105
+ const skipFormatFields = new Set<string>();
106
+ const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
107
+
108
+ // Check if the serialize function would produce type errors against the baseline
109
+ // wire interface. Skip serialize generation when the baseline has required fields
110
+ // that aren't in the IR model — the generated serialize body would be missing those
111
+ // fields, causing TS2741 / TS2322 errors. The merger will preserve any existing
112
+ // hand-written serialize function.
113
+ let shouldSkipSerialize = serializerHasBaselineIncompatibility(model, baselineResponse, baselineDomain, ctx);
114
+ // Also skip if any nested model dependency had its serialize skipped — the generated
115
+ // serialize function would reference a non-existent serialize export.
116
+ if (!shouldSkipSerialize) {
117
+ for (const field of model.fields) {
118
+ for (const ref of collectSerializedModelRefs(field.type)) {
119
+ // Check both the original model name and its dedup canonical name
120
+ if (skippedSerializeModels.has(ref)) {
121
+ shouldSkipSerialize = true;
122
+ break;
123
+ }
124
+ const canon = dedup.get(ref);
125
+ if (canon && skippedSerializeModels.has(canon)) {
126
+ shouldSkipSerialize = true;
127
+ break;
128
+ }
129
+ }
130
+ if (shouldSkipSerialize) break;
131
+ }
132
+ }
133
+ if (shouldSkipSerialize) {
134
+ skippedSerializeModels.add(model.name);
135
+ }
136
+ if (useStringDates) {
137
+ // Global convention: skip date-time conversion for every date field
138
+ for (const field of model.fields) {
139
+ if (hasDateTimeConversion(field.type)) {
140
+ skipFormatFields.add(field.name);
141
+ }
142
+ }
143
+ }
144
+ if (baselineDomain) {
145
+ // Per-field baseline check: also skip any other format conversions
146
+ // (e.g., int64 → BigInt) when the baseline uses a simpler type
147
+ for (const field of model.fields) {
148
+ if (skipFormatFields.has(field.name)) continue;
149
+ const baselineField = baselineDomain.fields?.[fieldName(field.name)];
150
+ if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
151
+ skipFormatFields.add(field.name);
152
+ }
153
+ }
154
+ }
28
155
 
29
156
  // Find nested model refs that need their own serializer imports.
30
157
  // Only collect models that will actually be called in serialize/deserialize expressions
@@ -44,37 +171,63 @@ export function generateSerializers(models: Model[], ctx: EmitterContext): Gener
44
171
  `import type { ${domainName}, ${responseName} } from '${relativeImport(serializerPath, interfacePath)}';`,
45
172
  );
46
173
 
47
- // Import nested model deserializers/serializers
174
+ // Import nested model deserializers/serializers as a single merged import.
175
+ // pruneUnusedImports will strip any unused identifiers (e.g., serialize*
176
+ // when shouldSkipSerialize is true).
48
177
  for (const dep of nestedModelRefs) {
49
178
  const depService = modelToService.get(dep);
50
179
  const depDir = resolveDir(depService);
51
180
  const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
52
181
  const depName = resolveInterfaceName(dep, ctx);
53
- const imports = [`deserialize${depName}`, `serialize${depName}`];
54
- lines.push(`import { ${imports.join(', ')} } from '${relativeImport(serializerPath, depSerializerPath)}';`);
182
+ const rel = relativeImport(serializerPath, depSerializerPath);
183
+ lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
55
184
  }
56
185
  lines.push('');
57
186
 
58
187
  // Deserialize function (wire → domain) — deduplicate by camelCase name
59
188
  const seenDeserFields = new Set<string>();
60
- lines.push(`export const deserialize${domainName} = (`);
61
- lines.push(` response: ${responseName},`);
62
- lines.push(`): ${domainName} => ({`);
189
+ // Prefix param with _ when model has no fields to avoid unused-param warnings
190
+ const deserParamPrefix = model.fields.length === 0 ? '_' : '';
191
+ lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
192
+ lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
193
+ lines.push(`): ${domainName}${typeParams.usage} => ({`);
63
194
  for (const field of model.fields) {
64
195
  const domain = fieldName(field.name);
65
196
  if (seenDeserFields.has(domain)) continue;
66
197
  seenDeserFields.add(domain);
67
198
  const wire = wireFieldName(field.name);
68
199
  const wireAccess = `response.${wire}`;
69
- const expr = deserializeExpression(field.type, wireAccess, ctx);
200
+ const skip = skipFormatFields.has(field.name);
201
+ const expr = skip ? wireAccess : deserializeExpression(field.type, wireAccess, ctx);
202
+ // Treat new fields (not in baseline) as effectively optional: the merger
203
+ // can deep-merge them into existing interfaces but cannot update existing
204
+ // deserializer bodies, so the wire response may not contain them.
205
+ const isNewField = baselineDomain && !baselineDomain.fields?.[domain];
206
+ const effectivelyOptional = !field.required || isNewField;
70
207
  // 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,`);
208
+ // wrap with a null check to avoid passing undefined to the deserializer.
209
+ // When the field type is nullable, preserve null on the wire instead of
210
+ // converting it to undefined (APIs distinguish null from absent).
211
+ if (effectivelyOptional && expr !== wireAccess && needsNullGuard(field.type)) {
212
+ const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
213
+ // If the expression already starts with a null guard from nullable handling,
214
+ // don't wrap it again — just replace the inner null fallback
215
+ if (expr.startsWith(`${wireAccess} != null ?`)) {
216
+ lines.push(` ${domain}: ${expr.replace(/: null$/, `: ${fallback}`)},`);
217
+ } else {
218
+ lines.push(` ${domain}: ${wireAccess} != null ? ${expr} : ${fallback},`);
219
+ }
74
220
  } 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);
221
+ // Required field with direct assignment — only add a fallback when the
222
+ // response interface makes the field optional (baseline override mismatch)
223
+ // or the field is newly added. When the field is required on BOTH
224
+ // interfaces, the response always contains it — no fallback is needed.
225
+ // This prevents incorrect fallbacks like ?? '' on string|null fields
226
+ // and invalid enum fallbacks like ?? 'Pending'.
227
+ const responseFieldInfo = baselineResponse?.fields?.[wire];
228
+ const responseFieldOptional = responseFieldInfo?.optional ?? false;
229
+ const needsFallback = responseFieldOptional || isNewField;
230
+ const fallback = needsFallback ? defaultForType(field.type) : null;
78
231
  if (fallback) {
79
232
  lines.push(` ${domain}: ${expr} ?? ${fallback},`);
80
233
  } else {
@@ -84,35 +237,108 @@ export function generateSerializers(models: Model[], ctx: EmitterContext): Gener
84
237
  lines.push(` ${domain}: ${expr},`);
85
238
  }
86
239
  }
240
+ // NOTE: Previously we added passthrough assignments for baseline-required fields
241
+ // missing from the IR model. This was removed because it creates type errors:
242
+ // the serializer would output fields that don't exist on the generated interface
243
+ // (e.g., Connection.type, AuditLogSchema.createdAt). If a baseline field is
244
+ // truly needed, the merger will preserve the existing serializer for that field.
87
245
  lines.push('});');
88
246
 
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},`);
247
+ // Serialize function (domain → wire) — skip when the baseline wire interface
248
+ // has required fields not covered by the IR model (the merger will preserve
249
+ // any existing hand-written serialize function).
250
+ if (!shouldSkipSerialize) {
251
+ const serParamPrefix = model.fields.length === 0 ? '_' : '';
252
+ lines.push('');
253
+ lines.push(`export const serialize${domainName} = ${typeParams.decl}(`);
254
+ lines.push(` ${serParamPrefix}model: ${domainName}${typeParams.usage},`);
255
+ lines.push(`): ${responseName}${typeParams.usage} => ({`);
256
+ const seenSerFields = new Set<string>();
257
+ for (const field of model.fields) {
258
+ const wire = wireFieldName(field.name);
259
+ if (seenSerFields.has(wire)) continue;
260
+ seenSerFields.add(wire);
261
+ const domain = fieldName(field.name);
262
+ const domainAccess = `model.${domain}`;
263
+ const skip = skipFormatFields.has(field.name);
264
+ const expr = skip ? domainAccess : serializeExpression(field.type, domainAccess, ctx);
265
+ // Treat new fields (not in baseline) as effectively optional — see deserializer comment above.
266
+ const isNewSerField = baselineDomain && !baselineDomain.fields?.[domain];
267
+ const effectivelyOptionalSer = !field.required || isNewSerField;
268
+
269
+ // Check if the domain field is optional but the baseline wire field is required.
270
+ // The serializer assigns `T | undefined` to the wire field, but the wire interface
271
+ // expects `T`. Add `?? null` coalesce to strip undefined and satisfy the wire type.
272
+ const baselineWireField = baselineResponse?.fields?.[wire];
273
+ const baselineDomainField = baselineDomain?.fields?.[domain];
274
+ // The domain field is optional if: (a) the IR says it's optional, (b) the baseline says it's optional,
275
+ // or (c) the baseline domain exists but doesn't have this field name (it's a "new field on existing model"
276
+ // and the generated interface makes it optional). Case (c) covers renamed fields (e.g., baseline
277
+ // uses `type` but the generated interface uses `connectionType`).
278
+ const isNewFieldOnExistingDomain = baselineDomain && !baselineDomainField;
279
+ const domainFieldIsOptional =
280
+ !field.required || (baselineDomainField?.optional ?? false) || !!isNewFieldOnExistingDomain;
281
+ const wireFieldIsRequired = baselineWireField ? !baselineWireField.optional : field.required;
282
+ const needsUndefinedCoalesce = domainFieldIsOptional && wireFieldIsRequired && expr === domainAccess;
283
+
284
+ // If the expression involves a function call (nested model/array serializer),
285
+ // wrap with a null check to prevent crashes when callers pass partial objects
286
+ // (e.g., `{} as any` in tests).
287
+ // When the field type is nullable, preserve null instead of undefined.
288
+ // Guard nullable and optional nested model/array-of-model fields.
289
+ // Required non-nullable fields are not guarded — the caller must provide them.
290
+ const shouldGuardSer = effectivelyOptionalSer || field.type.kind === 'nullable';
291
+ if (expr !== domainAccess && needsNullGuard(field.type) && shouldGuardSer) {
292
+ // For nullable fields, fallback to null. For optional fields, fallback to undefined.
293
+ const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
294
+ if (expr.startsWith(`${domainAccess} != null ?`)) {
295
+ lines.push(` ${wire}: ${expr.replace(/: null$/, `: ${fallback}`)},`);
296
+ } else {
297
+ lines.push(` ${wire}: ${domainAccess} != null ? ${expr} : ${fallback},`);
298
+ }
299
+ } else if (needsUndefinedCoalesce) {
300
+ // Domain field is optional (T | undefined) but wire field is required (T or T | null).
301
+ // When the wire type includes null, coalesce undefined → null.
302
+ // Otherwise, use a non-null assertion — the consumer is responsible for
303
+ // providing required fields when calling serialize, so undefined at runtime
304
+ // indicates a programming error on the caller's side.
305
+ const wireHasNull = baselineWireField?.type?.includes('null') || field.type.kind === 'nullable';
306
+ if (wireHasNull) {
307
+ lines.push(` ${wire}: ${expr} ?? null,`);
308
+ } else {
309
+ lines.push(` ${wire}: ${expr}!,`);
310
+ }
311
+ } else if (field.type.kind === 'nullable' && expr === domainAccess) {
312
+ // Check if the domain interface makes this field optional (T | null | undefined).
313
+ // This can happen when: (a) the IR says not required, (b) the field is new on
314
+ // an existing model, or (c) the baseline domain is required but the baseline
315
+ // response is optional (domainResponseOptionalMismatch in models.ts).
316
+ // In all these cases, the domain type includes `undefined` but the wire type
317
+ // may only accept `T | null`, so coalesce undefined → null.
318
+ const domainWireField2 = wireFieldName(field.name);
319
+ const responseBaselineField2 = baselineResponse?.fields?.[domainWireField2];
320
+ const baselineDomainField2 = baselineDomain?.fields?.[domain];
321
+ const domainResponseMismatch =
322
+ baselineDomainField2 &&
323
+ !baselineDomainField2.optional &&
324
+ responseBaselineField2 &&
325
+ responseBaselineField2.optional;
326
+ const fieldEffectivelyOptional = !field.required || isNewSerField || !!domainResponseMismatch;
327
+ if (fieldEffectivelyOptional) {
328
+ lines.push(` ${wire}: ${expr} ?? null,`);
329
+ } else {
330
+ lines.push(` ${wire}: ${expr},`);
331
+ }
332
+ } else {
333
+ lines.push(` ${wire}: ${expr},`);
334
+ }
108
335
  }
336
+ lines.push('});');
109
337
  }
110
- lines.push('});');
111
338
 
112
339
  files.push({
113
340
  path: serializerPath,
114
- content: lines.join('\n'),
115
- skipIfExists: true,
341
+ content: pruneUnusedImports(lines).join('\n'),
116
342
  });
117
343
  }
118
344
 
@@ -135,6 +361,9 @@ function collectSerializedModelRefs(ref: TypeRef): string[] {
135
361
  return collectSerializedModelRefs(ref.inner);
136
362
  case 'union': {
137
363
  const models = uniqueModelVariants(ref);
364
+ // Discriminated unions and allOf unions need serializers for all model variants
365
+ if (ref.discriminator && models.length > 0) return models;
366
+ if (ref.compositionKind === 'allOf' && models.length > 0) return models;
138
367
  // Only if exactly one unique model variant — that's when we call its serializer
139
368
  if (models.length === 1) return models;
140
369
  return [];
@@ -150,6 +379,7 @@ function collectSerializedModelRefs(ref: TypeRef): string[] {
150
379
  function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterContext): string {
151
380
  switch (ref.kind) {
152
381
  case 'primitive':
382
+ return deserializePrimitive(ref, wireExpr);
153
383
  case 'literal':
154
384
  case 'enum':
155
385
  return wireExpr;
@@ -173,6 +403,14 @@ function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterConte
173
403
  return `${wireExpr} ?? null`;
174
404
  }
175
405
  case 'union': {
406
+ // Discriminated union: switch on the discriminator property
407
+ if (ref.discriminator) {
408
+ return renderDiscriminatorSwitch(ref, wireExpr, 'deserialize', ctx);
409
+ }
410
+ // allOf union: merge all model variant fields via spread
411
+ if (ref.compositionKind === 'allOf') {
412
+ return renderAllOfMerge(ref, wireExpr, 'deserialize', ctx);
413
+ }
176
414
  // If the union has exactly one unique model variant, deserialize using that model's deserializer
177
415
  const deserModelVariants = uniqueModelVariants(ref);
178
416
  if (deserModelVariants.length === 1) {
@@ -189,6 +427,7 @@ function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterConte
189
427
  function serializeExpression(ref: TypeRef, domainExpr: string, ctx: EmitterContext): string {
190
428
  switch (ref.kind) {
191
429
  case 'primitive':
430
+ return serializePrimitive(ref, domainExpr);
192
431
  case 'literal':
193
432
  case 'enum':
194
433
  return domainExpr;
@@ -212,6 +451,14 @@ function serializeExpression(ref: TypeRef, domainExpr: string, ctx: EmitterConte
212
451
  return domainExpr;
213
452
  }
214
453
  case 'union': {
454
+ // Discriminated union: switch on the discriminator property
455
+ if (ref.discriminator) {
456
+ return renderDiscriminatorSwitch(ref, domainExpr, 'serialize', ctx);
457
+ }
458
+ // allOf union: merge all model variant fields via spread
459
+ if (ref.compositionKind === 'allOf') {
460
+ return renderAllOfMerge(ref, domainExpr, 'serialize', ctx);
461
+ }
215
462
  // If the union has exactly one unique model variant, serialize using that model's serializer
216
463
  const serModelVariants = uniqueModelVariants(ref);
217
464
  if (serModelVariants.length === 1) {
@@ -238,25 +485,107 @@ function uniqueModelVariants(ref: UnionType): string[] {
238
485
  }
239
486
 
240
487
  /**
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.
488
+ * Check whether a TypeRef involves a model reference or format conversion
489
+ * that would produce a function call in serialization/deserialization.
490
+ * Used to determine whether optional fields need a null guard wrapper.
244
491
  */
245
492
  function needsNullGuard(ref: TypeRef): boolean {
246
493
  switch (ref.kind) {
247
494
  case 'model':
248
495
  return true;
496
+ case 'primitive':
497
+ return hasFormatConversion(ref);
249
498
  case 'array':
250
499
  return ref.items.kind === 'model';
251
500
  case 'nullable':
252
501
  return needsNullGuard(ref.inner);
253
502
  case 'union':
503
+ if (ref.discriminator) return true;
504
+ if (ref.compositionKind === 'allOf' && uniqueModelVariants(ref).length > 0) return true;
254
505
  return uniqueModelVariants(ref).length === 1;
255
506
  default:
256
507
  return false;
257
508
  }
258
509
  }
259
510
 
511
+ /** Check if a type has a format that requires conversion. */
512
+ function hasFormatConversion(ref: TypeRef): boolean {
513
+ switch (ref.kind) {
514
+ case 'primitive':
515
+ return ref.format === 'date-time' || ref.format === 'int64';
516
+ case 'nullable':
517
+ return hasFormatConversion(ref.inner);
518
+ default:
519
+ return false;
520
+ }
521
+ }
522
+
523
+ /** Check if a type specifically has a date-time format conversion. */
524
+ function hasDateTimeConversion(ref: TypeRef): boolean {
525
+ switch (ref.kind) {
526
+ case 'primitive':
527
+ return ref.format === 'date-time';
528
+ case 'nullable':
529
+ return hasDateTimeConversion(ref.inner);
530
+ default:
531
+ return false;
532
+ }
533
+ }
534
+
535
+ /** Deserialize a primitive value, applying format conversions when needed. */
536
+ function deserializePrimitive(ref: PrimitiveType, wireExpr: string): string {
537
+ if (ref.format === 'date-time') return `new Date(${wireExpr})`;
538
+ if (ref.format === 'int64') return `BigInt(${wireExpr})`;
539
+ return wireExpr;
540
+ }
541
+
542
+ /** Serialize a primitive value, applying format conversions when needed. */
543
+ function serializePrimitive(ref: PrimitiveType, domainExpr: string): string {
544
+ if (ref.format === 'date-time') return `${domainExpr}.toISOString()`;
545
+ if (ref.format === 'int64') return `String(${domainExpr})`;
546
+ return domainExpr;
547
+ }
548
+
549
+ /**
550
+ * Render a discriminated union switch expression.
551
+ * Produces an IIFE that switches on the discriminator property and calls
552
+ * the appropriate serializer/deserializer for each mapped model.
553
+ */
554
+ function renderDiscriminatorSwitch(
555
+ ref: UnionType,
556
+ expr: string,
557
+ direction: 'deserialize' | 'serialize',
558
+ ctx: EmitterContext,
559
+ ): string {
560
+ const disc = ref.discriminator!;
561
+ const cases: string[] = [];
562
+ for (const [value, modelName] of Object.entries(disc.mapping)) {
563
+ const resolved = resolveInterfaceName(modelName, ctx);
564
+ const fn = `${direction}${resolved}`;
565
+ cases.push(`case '${value}': return ${fn}(${expr} as any)`);
566
+ }
567
+ return `(() => { switch ((${expr} as any).${disc.property}) { ${cases.join('; ')}; default: return ${expr} } })()`;
568
+ }
569
+
570
+ /**
571
+ * Render an allOf merge expression.
572
+ * Spreads the serialized/deserialized result of each model variant.
573
+ */
574
+ function renderAllOfMerge(
575
+ ref: UnionType,
576
+ expr: string,
577
+ direction: 'deserialize' | 'serialize',
578
+ ctx: EmitterContext,
579
+ ): string {
580
+ const models = uniqueModelVariants(ref);
581
+ if (models.length === 0) return expr;
582
+ const spreads = models.map((name) => {
583
+ const resolved = resolveInterfaceName(name, ctx);
584
+ return `...${direction}${resolved}(${expr} as any)`;
585
+ });
586
+ return `({ ${spreads.join(', ')} })`;
587
+ }
588
+
260
589
  /**
261
590
  * Return a TypeScript default value expression for a type, used as a null
262
591
  * coalesce fallback when a required domain field may be optional in the
@@ -264,8 +593,21 @@ function needsNullGuard(ref: TypeRef): boolean {
264
593
  */
265
594
  function defaultForType(ref: TypeRef): string | null {
266
595
  switch (ref.kind) {
596
+ case 'literal':
597
+ // Use the literal value itself as the fallback (e.g., 'role' for object: 'role')
598
+ return typeof ref.value === 'string' ? `'${ref.value}'` : String(ref.value);
599
+ case 'enum':
600
+ // Don't provide enum fallbacks — the first enum value may not be a valid
601
+ // member of the target type (e.g., 'Pending' is not a member of ConnectionType).
602
+ // If the field is required, the API always sends it; if the response baseline
603
+ // says optional, null/undefined is safer than guessing a value.
604
+ return null;
267
605
  case 'map':
268
606
  return '{}';
607
+ case 'nullable':
608
+ // Nullable fields should fall back to null, not the inner type's default.
609
+ // This prevents incorrect conversions like nullable<string> → '' instead of null.
610
+ return 'null';
269
611
  case 'primitive':
270
612
  switch (ref.type) {
271
613
  case 'boolean':
@@ -284,3 +626,119 @@ function defaultForType(ref: TypeRef): string | null {
284
626
  return null;
285
627
  }
286
628
  }
629
+
630
+ /**
631
+ * Check if the generated serialize function would produce type errors
632
+ * against the baseline wire (response) interface. Returns true when:
633
+ * - The baseline response interface has required fields whose wire name
634
+ * doesn't match any IR model field → TS2741 missing property.
635
+ * - The baseline domain interface has required fields whose camelCase name
636
+ * doesn't match any IR model field → the serializer would produce
637
+ * expressions referencing domain fields that don't exist on the baseline.
638
+ * - The baseline response has a required array field whose type references
639
+ * a different module than where the serializer imports its nested serializer.
640
+ */
641
+ function serializerHasBaselineIncompatibility(
642
+ model: Model,
643
+ baselineResponse: { fields?: Record<string, { type: string; optional: boolean }> } | undefined,
644
+ baselineDomain?: { fields?: Record<string, { type: string; optional: boolean }> },
645
+ ctx?: EmitterContext,
646
+ ): boolean {
647
+ if (!baselineResponse?.fields) return false;
648
+
649
+ // Collect all wire-format field names the IR model will produce
650
+ const irWireFields = new Set<string>();
651
+ const irDomainFields = new Set<string>();
652
+ for (const field of model.fields) {
653
+ irWireFields.add(wireFieldName(field.name));
654
+ irDomainFields.add(fieldName(field.name));
655
+ }
656
+
657
+ // Check if the baseline response has required fields not in the IR model
658
+ for (const [wireField2, fieldDef] of Object.entries(baselineResponse.fields)) {
659
+ if (fieldDef.optional) continue; // Optional fields won't cause TS errors
660
+ if (!irWireFields.has(wireField2)) {
661
+ // Baseline requires a field the IR doesn't produce → type error
662
+ return true;
663
+ }
664
+ }
665
+
666
+ // Check if the baseline domain has required fields whose names differ from
667
+ // what the IR would generate (e.g., baseline uses `type` but IR maps
668
+ // `connection_type` → `connectionType`). If there are required fields in
669
+ // the baseline domain that the IR doesn't produce, the serializer would read
670
+ // from domain fields that may have incompatible types.
671
+ if (baselineDomain?.fields) {
672
+ const baselineRequiredFields = Object.entries(baselineDomain.fields)
673
+ .filter(([, f]) => !f.optional)
674
+ .map(([name]) => name);
675
+ // Count how many baseline required fields are NOT in the IR domain fields.
676
+ // If more than 1/3 of required fields are unrecognized, assume significant
677
+ // structural differences → skip serialize to avoid type mismatches.
678
+ const unmatchedCount = baselineRequiredFields.filter((n) => !irDomainFields.has(n)).length;
679
+ if (unmatchedCount > 0 && baselineRequiredFields.length > 0) {
680
+ const unmatchedRatio = unmatchedCount / baselineRequiredFields.length;
681
+ if (unmatchedRatio > 0.3) {
682
+ return true;
683
+ }
684
+ }
685
+ }
686
+
687
+ // Check for nested model type mismatches: when the baseline response interface
688
+ // references a nested model type whose source file is in a DIFFERENT directory
689
+ // than the serializer's parent directory. The generated serializer creates local
690
+ // copies of nested model interfaces (via the model generator), and these local
691
+ // copies are structurally similar but TypeScript treats them as different types.
692
+ // Example: OrganizationDomainResponse from organization-domains/interfaces/ vs
693
+ // the generated copy in organizations/interfaces/ — same structure, different modules.
694
+ if (ctx?.apiSurface?.interfaces) {
695
+ // Determine the serializer's parent directory from the model name
696
+ const modelSourceFile = (baselineResponse as any)?.sourceFile as string | undefined;
697
+ const responseDir = modelSourceFile ? modelSourceFile.split('/').slice(0, 2).join('/') : null;
698
+
699
+ for (const field of model.fields) {
700
+ // Unwrap nullable to get the inner model type
701
+ let fieldType = field.type;
702
+ if (fieldType.kind === 'nullable') fieldType = fieldType.inner;
703
+ if (fieldType.kind !== 'array' && fieldType.kind !== 'model') continue;
704
+ const innerType = fieldType.kind === 'array' ? fieldType.items : fieldType;
705
+ if (innerType.kind !== 'model') continue;
706
+
707
+ const nestedWireName = wireInterfaceName(resolveInterfaceName(innerType.name, ctx));
708
+ const wireField3 = wireFieldName(field.name);
709
+ const baselineWireField2 = baselineResponse.fields[wireField3];
710
+ if (!baselineWireField2) continue;
711
+
712
+ // Check for type name mismatch: the baseline wire field references a type
713
+ // that is different from what the generated serializer would produce.
714
+ // e.g., baseline has `role: RoleResponse` but the deduped serializer returns
715
+ // `AddRolePermissionResponse`.
716
+ const baselineTypeNames: string[] = baselineWireField2.type.match(/\b[A-Z][a-zA-Z0-9]*Response\b/g) || [];
717
+ if (baselineTypeNames.length > 0 && !baselineTypeNames.includes(nestedWireName)) {
718
+ // The baseline expects a different Response type than the serializer produces
719
+ return true;
720
+ }
721
+
722
+ // Check if the baseline wire field type includes the nested wire type name
723
+ if (baselineWireField2.type.includes(nestedWireName) || baselineWireField2.type.match(/\b[A-Z]\w*Response\b/)) {
724
+ // Extract type names from the baseline field type
725
+ const typeNames: string[] = baselineWireField2.type.match(/\b[A-Z][a-zA-Z0-9]*\b/g) || [];
726
+ for (const typeName of typeNames) {
727
+ if (typeName === 'Record' || typeName === 'Array') continue;
728
+ const nestedIface = ctx.apiSurface.interfaces[typeName];
729
+ if (!nestedIface) continue;
730
+ const nestedSrc = (nestedIface as any).sourceFile as string | undefined;
731
+ if (!nestedSrc || !responseDir) continue;
732
+ const nestedDir = nestedSrc.split('/').slice(0, 2).join('/');
733
+ if (nestedDir !== responseDir) {
734
+ // The baseline response uses a type from a different directory than
735
+ // where the response itself lives → cross-module type incompatibility
736
+ return true;
737
+ }
738
+ }
739
+ }
740
+ }
741
+ }
742
+
743
+ return false;
744
+ }