@workos/oagen-emitters 0.16.1 → 0.18.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.
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-CpO8rePT.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-DAa-HsN5.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -42,18 +42,18 @@
42
42
  "@commitlint/config-conventional": "^21.0.2",
43
43
  "@types/node": "^25.9.3",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.54.0",
46
- "oxlint": "^1.69.0",
45
+ "oxfmt": "^0.55.0",
46
+ "oxlint": "^1.70.0",
47
47
  "prettier": "^3.8.4",
48
- "tsdown": "^0.22.2",
48
+ "tsdown": "^0.22.3",
49
49
  "tsx": "^4.22.4",
50
50
  "typescript": "^6.0.3",
51
- "vitest": "^4.1.8"
51
+ "vitest": "^4.1.9"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.22.5"
57
+ "@workos/oagen": "^0.22.6"
58
58
  }
59
59
  }
package/src/go/index.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
 
12
12
  import { generateModels } from './models.js';
13
13
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
14
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
14
15
  import { generateEnums } from './enums.js';
15
16
  import { generateResources } from './resources.js';
16
17
  import { generateClient } from './client.js';
@@ -47,7 +48,11 @@ export const goEmitter: Emitter = {
47
48
  }
48
49
  return m;
49
50
  });
50
- return ensureTrailingNewlines(generateModels(goModels, ctx));
51
+ // Go has no sum types: a discriminated-union field (e.g. ApiKey.owner)
52
+ // renders as its first variant, dropping fields that only exist on later
53
+ // variants (organization_id on the user owner). Flatten such unions into a
54
+ // single superset struct so every variant field survives.
55
+ return ensureTrailingNewlines(generateModels(flattenDiscriminatedUnionFields(goModels), ctx));
51
56
  },
52
57
 
53
58
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -18,6 +18,7 @@ import { generateClient } from './client.js';
18
18
  import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
21
22
 
22
23
  /** Ensure every generated file ends with a trailing newline. */
23
24
  function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
@@ -49,7 +50,11 @@ export const kotlinEmitter: Emitter = {
49
50
  }
50
51
  return m;
51
52
  });
52
- return ensureTrailingNewlines(generateModels(kotlinModels, ctx));
53
+ // Kotlin renders a discriminated-union field as its first variant's data
54
+ // class, so fields unique to later variants (organization_id on the user
55
+ // owner) are lost. Flatten such unions into one superset data class so
56
+ // every variant field is reachable.
57
+ return ensureTrailingNewlines(generateModels(flattenDiscriminatedUnionFields(kotlinModels), ctx));
53
58
  },
54
59
 
55
60
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -78,8 +83,9 @@ export const kotlinEmitter: Emitter = {
78
83
 
79
84
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
80
85
  // Pass enriched models so round-trip tests see the full field set
81
- // (including optional oneOf-enriched fields) and can filter accurately.
82
- const enrichedModels = enrichModelsFromSpec(spec.models);
86
+ // (including optional oneOf-enriched fields and flattened discriminated-
87
+ // union owner fields) and can filter accurately.
88
+ const enrichedModels = flattenDiscriminatedUnionFields(enrichModelsFromSpec(spec.models));
83
89
  const enrichedSpec: ApiSpec = { ...spec, models: enrichedModels };
84
90
  return ensureTrailingNewlines(generateTests(enrichedSpec, { ...ctx, spec: enrichedSpec }));
85
91
  },
@@ -50,13 +50,17 @@ interface FieldSpec {
50
50
  modelDeps: Set<string>;
51
51
  /** Whether the field requires date parsing/formatting (format: date-time). */
52
52
  hasDateTime: boolean;
53
+ /** Inline string-enum values, rendered as a literal union (e.g. `'a' | 'b'`). */
54
+ enumValues?: string[];
53
55
  }
54
56
 
55
57
  interface VariantSpec {
56
58
  /** Domain interface name suffix, e.g. `OAuth`, `M2M`. */
57
59
  nameSuffix: string;
58
- /** Discriminator string value, e.g. `oauth`, `m2m`. */
60
+ /** Discriminator value, e.g. `oauth`, `m2m`, or `true`/`false`. */
59
61
  discriminatorValue: string;
62
+ /** Whether the discriminator value is a boolean literal (emit unquoted). */
63
+ discriminatorIsBoolean?: boolean;
60
64
  /** Fields specific to this variant (excluding the discriminator). */
61
65
  fields: FieldSpec[];
62
66
  }
@@ -72,6 +76,13 @@ interface DiscriminatedShape {
72
76
  /** Description from the OpenAPI spec, if present. */
73
77
  discriminatorDescription?: string;
74
78
  variants: VariantSpec[];
79
+ /**
80
+ * Set for pure `oneOf` schemas (no `allOf` base wrapper). These are emitted
81
+ * as an inline anonymous union (`type X = { … } | { … }`) rather than as a
82
+ * set of named variant interfaces, which keeps two-variant unions — e.g. the
83
+ * boolean-discriminated `active: true | false` token response — readable.
84
+ */
85
+ inlineUnion?: boolean;
75
86
  }
76
87
 
77
88
  // ---------------------------------------------------------------------------
@@ -83,25 +94,45 @@ export function detectDiscriminatedShape(
83
94
  rawSchemas: Record<string, RawSchema>,
84
95
  ): DiscriminatedShape | null {
85
96
  const schema = rawSchemas[modelName];
86
- if (!schema?.allOf) return null;
97
+ if (!schema) return null;
87
98
 
88
- // The expected shape: allOf contains exactly one base object and one oneOf
89
- // wrapper. The base contributes shared fields; the oneOf contributes
90
- // variant-specific fields.
91
99
  let baseObject: RawSchema | null = null;
92
100
  let oneOfVariants: RawSchema[] | null = null;
93
- for (const member of schema.allOf) {
94
- const resolved = resolveRef(member, rawSchemas);
95
- if (resolved.oneOf) {
96
- if (oneOfVariants) return null; // unexpected: multiple oneOf branches at top
97
- oneOfVariants = resolved.oneOf;
98
- } else if (resolved.properties) {
99
- baseObject = mergeBase(baseObject, resolved);
100
- } else if (resolved.allOf) {
101
- // Nested allOf at top: walk it
102
- const nestedBase = flattenObjectAllOf(resolved, rawSchemas);
103
- baseObject = mergeBase(baseObject, nestedBase);
101
+ let inlineUnion = false;
102
+
103
+ if (schema.allOf) {
104
+ // `allOf [base, oneOf [variant, …]]`: the base contributes shared fields;
105
+ // the oneOf contributes variant-specific fields. Emitted as named variant
106
+ // interfaces (one per variant) plus a union alias.
107
+ for (const member of schema.allOf) {
108
+ const resolved = resolveRef(member, rawSchemas);
109
+ if (resolved.oneOf) {
110
+ if (oneOfVariants) return null; // unexpected: multiple oneOf branches at top
111
+ oneOfVariants = resolved.oneOf;
112
+ } else if (resolved.properties) {
113
+ baseObject = mergeBase(baseObject, resolved);
114
+ } else if (resolved.allOf) {
115
+ // Nested allOf at top: walk it
116
+ const nestedBase = flattenObjectAllOf(resolved, rawSchemas);
117
+ baseObject = mergeBase(baseObject, nestedBase);
118
+ }
104
119
  }
120
+ } else if (schema.oneOf && schema.oneOf.length >= 2) {
121
+ // Pure `oneOf` (no base wrapper): every branch must be a plain inline
122
+ // object so a shared const discriminator can tell them apart. This is the
123
+ // discriminated-union shape the parser would otherwise flatten into a
124
+ // single all-optional interface (e.g. the boolean-discriminated token
125
+ // response `{ active: true; … } | { active: false; … }`). The
126
+ // mutually-exclusive-field-group oneOf (no shared discriminator) is left
127
+ // alone by the `findSharedDiscriminator` check below.
128
+ const allInlineObjects = schema.oneOf.every(
129
+ (v) => v.properties && !v.$ref && (v.type === 'object' || !v.type) && !v.allOf && !v.oneOf,
130
+ );
131
+ if (!allInlineObjects) return null;
132
+ oneOfVariants = schema.oneOf;
133
+ inlineUnion = true;
134
+ } else {
135
+ return null;
105
136
  }
106
137
  if (!oneOfVariants || oneOfVariants.length < 2) return null;
107
138
 
@@ -120,12 +151,13 @@ export function detectDiscriminatedShape(
120
151
 
121
152
  // Build variant specs.
122
153
  const variants: VariantSpec[] = flattenedVariants
123
- .map((fv) => {
124
- const discValue = readConstString(fv.alwaysProperties.get(discProp));
125
- if (!discValue) return null;
154
+ .map((fv): VariantSpec | null => {
155
+ const discValue = readConst(fv.alwaysProperties.get(discProp));
156
+ if (discValue === null) return null;
126
157
  return {
127
- nameSuffix: variantNameSuffix(discValue),
128
- discriminatorValue: discValue,
158
+ nameSuffix: variantNameSuffix(String(discValue)),
159
+ discriminatorValue: String(discValue),
160
+ discriminatorIsBoolean: typeof discValue === 'boolean',
129
161
  fields: variantFields(fv, discProp, modelName),
130
162
  };
131
163
  })
@@ -144,6 +176,7 @@ export function detectDiscriminatedShape(
144
176
  discriminatorPropertyDomain: toCamelCase(discProp),
145
177
  discriminatorDescription,
146
178
  variants,
179
+ inlineUnion,
147
180
  };
148
181
  }
149
182
 
@@ -302,12 +335,12 @@ function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
302
335
  const values: string[] = [];
303
336
  for (const v of variants) {
304
337
  const schema = v.alwaysProperties.get(propName);
305
- const val = readConstString(schema);
338
+ const val = readConst(schema);
306
339
  if (val === null) {
307
340
  allConst = false;
308
341
  break;
309
342
  }
310
- values.push(val);
343
+ values.push(String(val));
311
344
  }
312
345
  if (allConst && new Set(values).size === values.length) {
313
346
  return propName;
@@ -316,11 +349,17 @@ function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
316
349
  return null;
317
350
  }
318
351
 
319
- function readConstString(schema: RawSchema | undefined | null): string | null {
352
+ /**
353
+ * Read a discriminator value pinned by `const` (or a single-value `enum`).
354
+ * Supports string and boolean literals — the latter drives the
355
+ * `active: true | false` style token response union.
356
+ */
357
+ function readConst(schema: RawSchema | undefined | null): string | boolean | null {
320
358
  if (!schema) return null;
321
- if (typeof schema.const === 'string') return schema.const;
322
- if (Array.isArray(schema.enum) && schema.enum.length === 1 && typeof schema.enum[0] === 'string') {
323
- return schema.enum[0];
359
+ if (typeof schema.const === 'string' || typeof schema.const === 'boolean') return schema.const;
360
+ if (Array.isArray(schema.enum) && schema.enum.length === 1) {
361
+ const only = schema.enum[0];
362
+ if (typeof only === 'string' || typeof only === 'boolean') return only;
324
363
  }
325
364
  return null;
326
365
  }
@@ -360,6 +399,10 @@ function buildField(rawName: string, schema: RawSchema, required: boolean, paren
360
399
  const modelDeps = new Set<string>();
361
400
  const domainType = rawSchemaToTS(schema, parentName, rawName, false, modelDeps);
362
401
  const wireType = rawSchemaToTS(schema, parentName, rawName, true, modelDeps);
402
+ const enumValues =
403
+ Array.isArray(schema.enum) && schema.enum.length > 0 && schema.enum.every((e) => typeof e === 'string')
404
+ ? (schema.enum as string[])
405
+ : undefined;
363
406
  return {
364
407
  name: rawName,
365
408
  description: schema.description,
@@ -368,6 +411,7 @@ function buildField(rawName: string, schema: RawSchema, required: boolean, paren
368
411
  wireType,
369
412
  modelDeps,
370
413
  hasDateTime: schemaHasDateTime(schema),
414
+ enumValues,
371
415
  };
372
416
  }
373
417
 
@@ -487,6 +531,15 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
487
531
  depDirMap.set(rawName, irModelDir.get(stripped)!);
488
532
  }
489
533
  }
534
+ // Synthetic IR models — e.g. inline-object variant fields like the token
535
+ // response's nested `access_token` — have PascalCase names that never appear
536
+ // in rawSchemas, so the loop above misses them. Seed their directories from
537
+ // irModelDir so `collectImports` (which only receives `depDirMap` via the
538
+ // plan) can resolve a cross-service inline-object dep instead of defaulting
539
+ // to a possibly-wrong same-dir import.
540
+ for (const [irName, dir] of irModelDir) {
541
+ if (!depDirMap.has(irName)) depDirMap.set(irName, dir);
542
+ }
490
543
 
491
544
  for (const model of models) {
492
545
  const shape = detectDiscriminatedShape(model.name, rawSchemas);
@@ -503,7 +556,17 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
503
556
  for (const d of field.modelDeps) allDeps.add(d);
504
557
  }
505
558
  }
506
- const hasUnresolvableDeps = [...allDeps].some((dep) => !depDirMap.has(dep) && !irModelDir.has(dep));
559
+ // `modelDeps` may carry an inline-object synthetic name in raw form
560
+ // (`Parent_field`) while the resolution maps are keyed by the PascalCase IR
561
+ // model name (`ParentField`). Resolve either spelling before deciding a dep
562
+ // is unreachable — otherwise inline-object variant fields (e.g. the token
563
+ // response's nested `access_token`) would wrongly drop the whole plan.
564
+ const resolvable = (dep: string): boolean =>
565
+ depDirMap.has(dep) ||
566
+ irModelDir.has(dep) ||
567
+ depDirMap.has(toPascalCase(dep)) ||
568
+ irModelDir.has(toPascalCase(dep));
569
+ const hasUnresolvableDeps = [...allDeps].some((dep) => !resolvable(dep));
507
570
  if (hasUnresolvableDeps) continue;
508
571
  const modelDir = resolveDir(modelToService.get(model.name));
509
572
  plans.set(model.name, { shape, modelDir, depDirMap });
@@ -525,6 +588,7 @@ export function generateDiscriminatedFiles(
525
588
 
526
589
  function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): GeneratedFile {
527
590
  const { shape, modelDir } = plan;
591
+ if (shape.inlineUnion) return buildInlineUnionFile(plan);
528
592
  const domain = toPascalCase(shape.modelName);
529
593
  const wire = wireInterfaceName(domain);
530
594
  const lines: string[] = [];
@@ -561,6 +625,53 @@ function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gene
561
625
  };
562
626
  }
563
627
 
628
+ /**
629
+ * Emit a pure-`oneOf` discriminated union as a single inline type alias
630
+ * (`export type X = { … } | { … }`) for both the domain and wire shapes. Used
631
+ * instead of named per-variant interfaces, which read poorly for small
632
+ * (often two-variant, boolean-discriminated) unions like the token response.
633
+ */
634
+ function buildInlineUnionFile(plan: DiscriminatedPlan): GeneratedFile {
635
+ const { shape, modelDir } = plan;
636
+ const domain = toPascalCase(shape.modelName);
637
+ const wire = wireInterfaceName(domain);
638
+ const lines: string[] = [];
639
+
640
+ const imports = collectImports(plan);
641
+ if (imports.length > 0) {
642
+ for (const imp of imports) {
643
+ lines.push(`import type { ${imp.symbols.join(', ')} } from '${imp.path}';`);
644
+ }
645
+ lines.push('');
646
+ }
647
+
648
+ lines.push(...buildInlineUnionAlias(domain, shape, /*isWire*/ false));
649
+ lines.push('');
650
+ lines.push(...buildInlineUnionAlias(wire, shape, /*isWire*/ true));
651
+
652
+ return {
653
+ path: `src/${modelDir}/interfaces/${fileName(shape.modelName)}.interface.ts`,
654
+ content: lines.join('\n') + '\n',
655
+ overwriteExisting: true,
656
+ };
657
+ }
658
+
659
+ function buildInlineUnionAlias(name: string, shape: DiscriminatedShape, isWire: boolean): string[] {
660
+ const lines: string[] = [`export type ${name} =`];
661
+ shape.variants.forEach((variant, idx) => {
662
+ const isLast = idx === shape.variants.length - 1;
663
+ const discKey = isWire ? shape.discriminatorProperty : shape.discriminatorPropertyDomain;
664
+ const members = [`${discKey}: ${discLiteral(variant)}`];
665
+ for (const field of variant.fields) {
666
+ const key = isWire ? field.name : toCamelCase(field.name);
667
+ const opt = field.required ? '' : '?';
668
+ members.push(`${key}${opt}: ${inlineFieldType(field, isWire)}`);
669
+ }
670
+ lines.push(` | { ${members.join('; ')} }${isLast ? ';' : ''}`);
671
+ });
672
+ return lines;
673
+ }
674
+
564
675
  function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: VariantSpec, isWire: boolean): string[] {
565
676
  const lines: string[] = [];
566
677
  lines.push(`export interface ${name} {`);
@@ -578,7 +689,7 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
578
689
  if (shape.discriminatorDescription) {
579
690
  lines.push(` /** ${shape.discriminatorDescription} */`);
580
691
  }
581
- lines.push(` ${discKey}: '${variant.discriminatorValue}';`);
692
+ lines.push(` ${discKey}: ${discLiteral(variant)};`);
582
693
  // Variant-specific fields
583
694
  for (const field of variant.fields) {
584
695
  pushFieldLine(lines, field, isWire);
@@ -587,6 +698,31 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
587
698
  return lines;
588
699
  }
589
700
 
701
+ /**
702
+ * The discriminator value as a TS literal: quoted for strings (`'oauth'`),
703
+ * bare for booleans (`true`).
704
+ */
705
+ function discLiteral(variant: VariantSpec): string {
706
+ return variant.discriminatorIsBoolean ? variant.discriminatorValue : `'${variant.discriminatorValue}'`;
707
+ }
708
+
709
+ /**
710
+ * Field type for an inline-union member: a literal union for inline string
711
+ * enums, otherwise the resolved domain/wire type.
712
+ *
713
+ * The `enumValues` branch deliberately ignores `isWire`: `enumValues` is only
714
+ * populated for string enums (see `buildField`), whose domain and wire
715
+ * representations are identical literal unions. Any field whose domain/wire
716
+ * types actually differ (model refs, dates, non-string enums) leaves
717
+ * `enumValues` undefined and falls through to the type-specific branch.
718
+ */
719
+ function inlineFieldType(field: FieldSpec, isWire: boolean): string {
720
+ if (field.enumValues) {
721
+ return field.enumValues.map((v) => `'${v}'`).join(' | ');
722
+ }
723
+ return isWire ? field.wireType : field.domainType;
724
+ }
725
+
590
726
  function pushFieldLine(lines: string[], field: FieldSpec, isWire: boolean): void {
591
727
  const key = isWire ? field.name : toCamelCase(field.name);
592
728
  const opt = field.required ? '' : '?';
@@ -617,7 +753,10 @@ function collectImports(plan: DiscriminatedPlan): ImportSpec[] {
617
753
  const domain = toPascalCase(dep);
618
754
  const wire = wireInterfaceName(domain);
619
755
  const symbols = wire !== domain ? [domain, wire] : [domain];
620
- const depDir = plan.depDirMap.get(dep);
756
+ // `dep` may be a raw spec name or a synthetic inline-object name in snake
757
+ // form (`Parent_field`); `depDirMap` keys the latter under its PascalCase IR
758
+ // name, so fall back to that spelling before defaulting to a same-dir path.
759
+ const depDir = plan.depDirMap.get(dep) ?? plan.depDirMap.get(domain);
621
760
  const baseName = fileName(toSnakeFromPascal(domain));
622
761
  let importPath: string;
623
762
  if (!depDir || depDir === plan.modelDir) {
@@ -660,10 +799,16 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
660
799
  for (const variant of shape.variants)
661
800
  for (const field of variant.fields) for (const d of field.modelDeps) allDeps.add(d);
662
801
 
802
+ // Pure-`oneOf` unions are response shapes (e.g. the token response): they are
803
+ // only ever deserialized, and their inline-object fields may have no
804
+ // serializer generated. Emit a deserializer only and import just the
805
+ // deserialize helpers — mirrors the published hand-written serializer.
806
+ const deserializeOnly = shape.inlineUnion === true;
663
807
  for (const dep of [...allDeps].sort()) {
664
808
  const depDomain = toPascalCase(dep);
665
809
  const depFile = fileName(toSnakeFromPascal(depDomain));
666
- lines.push(`import { deserialize${depDomain}, serialize${depDomain} } from './${depFile}.serializer';`);
810
+ const helpers = deserializeOnly ? `deserialize${depDomain}` : `deserialize${depDomain}, serialize${depDomain}`;
811
+ lines.push(`import { ${helpers} } from './${depFile}.serializer';`);
667
812
  }
668
813
  lines.push('');
669
814
 
@@ -671,12 +816,12 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
671
816
  lines.push(`export const deserialize${domain} = (response: ${wire}): ${domain} => {`);
672
817
  lines.push(` switch (response.${shape.discriminatorProperty}) {`);
673
818
  for (const variant of shape.variants) {
674
- lines.push(` case '${variant.discriminatorValue}':`);
819
+ lines.push(` case ${discLiteral(variant)}:`);
675
820
  lines.push(` return {`);
676
821
  for (const field of shape.baseFields) {
677
822
  lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
678
823
  }
679
- lines.push(` ${shape.discriminatorPropertyDomain}: '${variant.discriminatorValue}',`);
824
+ lines.push(` ${shape.discriminatorPropertyDomain}: ${discLiteral(variant)},`);
680
825
  for (const field of variant.fields) {
681
826
  lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
682
827
  }
@@ -684,29 +829,31 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
684
829
  }
685
830
  lines.push(` default:`);
686
831
  lines.push(
687
- ` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${(response as { ${shape.discriminatorProperty}: string }).${shape.discriminatorProperty}}\`);`,
832
+ ` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${String((response as Record<string, unknown>).${shape.discriminatorProperty})}\`);`,
688
833
  );
689
834
  lines.push(` }`);
690
835
  lines.push(`};`);
691
- lines.push('');
692
836
 
693
- // Serializer
694
- lines.push(`export const serialize${domain} = (model: ${domain}): ${wire} => {`);
695
- lines.push(` switch (model.${shape.discriminatorPropertyDomain}) {`);
696
- for (const variant of shape.variants) {
697
- lines.push(` case '${variant.discriminatorValue}':`);
698
- lines.push(` return {`);
699
- for (const field of shape.baseFields) {
700
- lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
701
- }
702
- lines.push(` ${shape.discriminatorProperty}: '${variant.discriminatorValue}',`);
703
- for (const field of variant.fields) {
704
- lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
837
+ if (!deserializeOnly) {
838
+ lines.push('');
839
+ // Serializer
840
+ lines.push(`export const serialize${domain} = (model: ${domain}): ${wire} => {`);
841
+ lines.push(` switch (model.${shape.discriminatorPropertyDomain}) {`);
842
+ for (const variant of shape.variants) {
843
+ lines.push(` case ${discLiteral(variant)}:`);
844
+ lines.push(` return {`);
845
+ for (const field of shape.baseFields) {
846
+ lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
847
+ }
848
+ lines.push(` ${shape.discriminatorProperty}: ${discLiteral(variant)},`);
849
+ for (const field of variant.fields) {
850
+ lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
851
+ }
852
+ lines.push(` };`);
705
853
  }
706
- lines.push(` };`);
854
+ lines.push(` }`);
855
+ lines.push(`};`);
707
856
  }
708
- lines.push(` }`);
709
- lines.push(`};`);
710
857
 
711
858
  return {
712
859
  path: `src/${modelDir}/serializers/${fileName(shape.modelName)}.serializer.ts`,
package/src/node/index.ts CHANGED
@@ -18,6 +18,7 @@ import { generateResources, resolveResourceClassName, resolveResourceDir } from
18
18
  import { generateClient } from './client.js';
19
19
  import { generateTests as generateTestFiles } from './tests.js';
20
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
21
22
  import { planDiscriminatedModels, generateDiscriminatedFiles } from './discriminated-models.js';
22
23
  import {
23
24
  buildLiveSurface,
@@ -766,7 +767,7 @@ function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): Ge
766
767
  function enrichModelsForNode(models: Model[]): Model[] {
767
768
  const enriched = enrichModelsFromSpec(models);
768
769
  const originalByName = new Map(models.map((m) => [m.name, m]));
769
- return enriched.map((m) => {
770
+ const restored = enriched.map((m) => {
770
771
  if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
771
772
  const original = originalByName.get(m.name);
772
773
  if (original && original.fields.length > 0) {
@@ -775,6 +776,11 @@ function enrichModelsForNode(models: Model[]): Model[] {
775
776
  }
776
777
  return m;
777
778
  });
779
+ // Field-level discriminated unions (e.g. ApiKey.owner) otherwise render as
780
+ // `FirstVariant | SecondVariant`; collapse them to one flat superset
781
+ // interface so callers see every variant field (organization_id on the user
782
+ // owner) on a single type — parity with the other flat-emit languages.
783
+ return flattenDiscriminatedUnionFields(restored);
778
784
  }
779
785
 
780
786
  export const nodeEmitter: Emitter = {