Skip to content

fix(typescript): preserve base-properties on undiscriminated unions (TS + Python) - #17666

Open
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1788573464-undiscriminated-union-base-properties
Open

fix(typescript): preserve base-properties on undiscriminated unions (TS + Python)#17666
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1788573464-undiscriminated-union-base-properties

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Description

Linear ticket: Refs

When an OpenAPI oneOf has sibling properties/required, the importer correctly places the shared fields in UndiscriminatedUnionTypeDeclaration.baseProperties. Both the TypeScript and Python (v1) SDK generators ignored that field and emitted only members, so every shared property vanished from the generated model (e.g. MedicationOrderPayload lost 9 of 11 properties in a customer spec).

Base properties are now merged into every union member that resolves to a named object declaration; other members (primitives, optionals, maps, aliases to non-objects, ...) are left untouched, and unions without baseProperties generate exactly as before.

TypeScriptGeneratedUndiscriminatedUnionTypeImpl / GeneratedUndiscriminatedUnionTypeSchemaImpl

// type (also request/response variants + inline unions)
export type UnionWithBaseProperties =
    | (NamedMetadata & { id: string; category?: string | undefined })
    | OptionalMetadata
    | undefined;

// serde schema
core.serialization.undiscriminatedUnion([
    core.serialization.object({ id: core.serialization.string(), category: core.serialization.string().optional() }).extend(NamedMetadata),
    OptionalMetadata,
]);
// Raw = (NamedMetadata.Raw & { id: string; category?: string | null }) | (OptionalMetadata.Raw | undefined)

GeneratedUndiscriminatedUnionType gains appliesBasePropertiesToMember(context, member) and getBasePropertyKey({ propertyWireKey }) so the schema generator shares the type generator's applicability/casing logic.

Python (v1)AbstractUndiscriminatedUnionGenerator computes MemberWithBaseProperties (base props first, then the member's own properties incl. extensions, deduped by wire name). The pydantic and TypedDict generators emit a local <Union><Member> class per applicable member and reference it from the union alias:

class UnionWithBasePropertiesNamedMetadata(UniversalBaseModel):
    id: str
    category: typing.Optional[str] = None
    name: str
    value: typing.Dict[str, typing.Any]

UnionWithBaseProperties = typing.Union[UnionWithBasePropertiesNamedMetadata, OptionalMetadata]

Changes Made

  • TS type generator: intersect base-property type literal into object-like members (normal/request/response/inline).
  • TS schema generator: object(baseProps).extend(memberSchema) + raw type intersection for object-like members.
  • Python v1: local merged Pydantic model / TypedDict per object-like member.
  • Added a serde-layer output to the undiscriminated-unions ts-sdk seed fixture so the schema path is covered; regenerated undiscriminated-unions for ts-sdk and python-sdk.
  • Unreleased changelog entries for typescript/sdk and python/sdk.
  • Updated README.md generator (if applicable)

Testing

  • Unit tests added/updated — existing type-schema-generator mock extended for the new interface methods; type-generator, type-schema-generator, sdk-generator vitest suites pass.
  • Manual testing completed
    • seed test --generator ts-sdk --fixture undiscriminated-unions (3/3) and --generator python-sdk (1/1).
    • pnpm compile, pnpm lint:biome, pnpm format clean; poetry run mypy . (343 files) and poetry run pytest (399 passed) in generators/python.
    • Regenerated the reporter's Pharmetika spec: MedicationOrderPayload now carries all 9 shared fields on both the human and veterinary branches in TS and Python; generated Python passes mypy.

Link to Devin session: https://app.devin.ai/sessions/d7d3ec59fed14ea3ab2943cc09080757
Open in Devin Desktop: https://app.devin.ai/desktop/session/d7d3ec59fed14ea3ab2943cc09080757?variant=devin


Devin Review

…nions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

The PR merges undiscriminated-union baseProperties into object-like members for the TS type/schema generators and the Python v1 generators. The approach looks sound overall, but there are a few consistency gaps: the TS schema raw type uses wire keys while the parsed schema uses the casing-aware key (fine), yet noOptionalProperties handling and read/write-only variants differ between the type and schema paths; and the Python generator ignores union member extends/alias-of-object nuances in the typeddict should_export path (creating locally-exported helper classes). Also a couple of smaller correctness concerns flagged below.

  • 🟡 6 warning(s)
  • 🔵 2 suggestion(s)

To request another review, comment /ai-review on this pull request.

Comment on lines +40 to +47
return (
this.noOptionalProperties
? context.coreUtilities.zurg.objectWithoutOptionalProperties
: context.coreUtilities.zurg.object
)(basePropertySchemas).extend(
context.typeSchema.getSchemaOfNamedType(resolved.name, { isGeneratingSchema: true })
);
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

The parsed schema is built as object(baseProps).extend(memberSchema). extend in zurg produces the union of properties, but the raw key mapping for base props here uses raw: wireValue / parsed: getBasePropertyKey(...), while generateRawTypeDeclaration below emits the raw property with getPropertyKey(getWireValue(...)) — consistent. However, the ordering matters for conflicts: if a member object already declares a property with the same wire key as a base property, extend will have the member's schema win at runtime while the TS type intersection (type generator) yields A & B (intersection of both). Worth confirming duplicate wire keys between base properties and member properties can't produce a schema/type mismatch (the Python side explicitly dedupes; TS doesn't).

Comment on lines +160 to +175
return ts.factory.createParenthesizedType(
ts.factory.createIntersectionTypeNode([
memberNode,
ts.factory.createTypeLiteralNode(
baseProperties.map((property) =>
ts.factory.createPropertySignature(
undefined,
ts.factory.createIdentifier(property.name),
property.hasQuestionToken ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined,
selectTypeNode(property)
)
)
)
])
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

intersectWithBaseProperties recomputes getBasePropertyNodes(context) for every member. For a union with N members and M base properties this is N*M getReferenceToType calls per generated variant (x3 for normal/request/response). Hoist it out of the per-member loop (e.g. compute once in generateTypeNodes / lazily memoize) to avoid the redundant work.

return false;
}
const resolved = context.type.resolveTypeReference(member.type);
return resolved.type === "named" && resolved.shape === FernIr.ShapeType.Object;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

appliesBasePropertiesToMember only accepts members whose resolved shape is Object. The Python counterpart (_get_object_type_id) also follows aliases-to-objects. Does context.type.resolveTypeReference already unwrap named aliases here? If not, TS and Python will disagree on which members receive base properties (alias-to-object members get them in Python, not TS), which means the same IR generates divergent SDKs.

Comment on lines +53 to +63
const baseRawType = ts.factory.createTypeLiteralNode(
(this.shape.baseProperties ?? []).map((property) => {
const type = context.typeSchema.getReferenceToRawType(property.valueType);
return ts.factory.createPropertySignature(
undefined,
ts.factory.createIdentifier(getPropertyKey(getWireValue(property.name))),
type.isOptional ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined,
type.typeNodeWithoutUndefined
);
})
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

baseRawType is computed unconditionally even when no member is applicable (and even when baseProperties is empty, producing an empty type literal that's never used). Cheap, but guard it with this.shape.baseProperties?.length for clarity and to avoid pointless getReferenceToRawType calls.

Comment on lines +70 to +79
def _get_object_type_id(self, type_id: ir_types.TypeId) -> Optional[ir_types.TypeId]:
"""Returns the type id of the object declaration this named type resolves to, if any."""
shape = self._context.get_declaration_for_type_id(type_id).shape.get_as_union()
if shape.type == "object":
return type_id
if shape.type == "alias":
resolved = shape.resolved_type.get_as_union()
if resolved.type == "named":
return self._get_object_type_id(resolved.name.type_id)
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

_get_object_type_id recurses through named aliases with no cycle guard. A self-referential or mutually-referential alias chain (which the surrounding code explicitly acknowledges via CycleAwareMemberType) would recurse infinitely. Track visited type ids and bail out:

Suggested change
def _get_object_type_id(self, type_id: ir_types.TypeId) -> Optional[ir_types.TypeId]:
"""Returns the type id of the object declaration this named type resolves to, if any."""
shape = self._context.get_declaration_for_type_id(type_id).shape.get_as_union()
if shape.type == "object":
return type_id
if shape.type == "alias":
resolved = shape.resolved_type.get_as_union()
if resolved.type == "named":
return self._get_object_type_id(resolved.name.type_id)
return None
def _get_object_type_id(
self, type_id: ir_types.TypeId, _seen: Optional[Set[ir_types.TypeId]] = None
) -> Optional[ir_types.TypeId]:
"""Returns the type id of the object declaration this named type resolves to, if any."""
seen = _seen if _seen is not None else set()
if type_id in seen:
return None
seen.add(type_id)
shape = self._context.get_declaration_for_type_id(type_id).shape.get_as_union()
if shape.type == "object":
return type_id
if shape.type == "alias":
resolved = shape.resolved_type.get_as_union()
if resolved.type == "named":
return self._get_object_type_id(resolved.name.type_id, seen)
return None

(requires Set in the typing import)

member_name = resolve_name(member_union.name).pascal_case.safe_name
base_property_wire_names = {get_wire_value(property.name) for property in self._base_properties}
return MemberWithBaseProperties(
class_name=f"{self._get_class_name(as_request=as_request)}{member_name}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

class_name is f"{UnionName}{MemberName}" with no collision check. Two distinct members whose pascal-cased names collide (e.g. foo_bar and fooBar, or an existing type literally named UnionWithBasePropertiesNamedMetadata) will produce duplicate class declarations in the same file. Consider deduping/uniquifying via the context's name registry.

if member_with_base is not None:
return AST.TypeHint(self._generate_member_with_base_properties(member_with_base))
return self._context.get_type_hint_for_type_reference(
member.type, as_if_type_checking_import=member.is_circular_reference

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

When the member is a circular reference (member.is_circular_reference), the base-properties path drops as_if_type_checking_import entirely and instead inlines a locally-declared Pydantic model whose fields reference the member's properties directly. For a genuinely recursive member this can reintroduce the import cycle / forward-ref problem the as_if_type_checking_import flag exists to avoid. Consider skipping the merge (falling back to the plain reference) when member.is_circular_reference is true, or verify with a recursive-union fixture.

source_file=self._source_file,
class_name=member_with_base.class_name,
original_type_id=self._name.type_id,
should_export=True,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

should_export=True exports the synthetic per-member TypedDict into the public API surface (as seen in the regenerated __init__.py files). That's a new public name derived from an implementation detail; if it's not intentional, prefer should_export=False. Note the pydantic generator's FernAwarePydanticModel call doesn't pass an equivalent flag, so the two paths may already differ in export behavior.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +53 to 57
members.map(({ member, ref }) =>
this.intersectWithBaseProperties(context, member, ref.typeNode, "normal")
)
),
requestTypeNode: ts.factory.createUnionTypeNode(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Generated snippets omit shared fields

After intersectWithBaseProperties adds required fields, example builders still emit only the original member. Generated TypeScript and Python snippets no longer satisfy the union.

Prompt for agents
Update TypeScript and Python example/snippet generation for undiscriminated unions with base properties. TypeScript GeneratedUndiscriminatedUnionTypeImpl.buildExample and Python AbstractUndiscriminatedUnionSnippetGenerator currently build only example.singleUnionType. For an applicable object member, merge the union example's base-property values and instantiate or emit the new merged member shape. Ensure generated endpoint snippets include required base fields and use the generated Python local member class where necessary. Add seed assertions for both languages.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-09-04T04:06:24Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
python-sdk square 152s (n=5) 254s (n=5) 142s -10s (-6.6%)
ts-sdk square 178s (n=5) 184s (n=5) 176s -2s (-1.1%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-09-04T04:06:24Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-09-05 02:51 UTC

…e base/member serde keys

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Addressed in e83d682: getBasePropertyNodes now records isReadonly/isWriteonly; intersectWithBaseProperties drops READ_ONLY props for request and WRITE_ONLY for response; module Request/Response aliases are emitted when only base props need them; TypeContextImpl.needsRequestResponseTypeVariantByType also walks baseProperties (access + nested value types).

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Addressed in e83d682: base properties whose wire key already exists on the resolved member object (including extended properties, via getAllPropertiesIncludingExtensions) are excluded from the base object(...) schema, so the member's own validator is the single owner of that key.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Not fixable in this PR: ExampleUndiscriminatedUnionType in the IR only carries index + singleUnionType — there are no base-property example values to merge (unlike ExampleUnionType.baseProperties). Emitting required base fields in snippets needs an IR addition plus CLI example-generator changes, which per the repo's workflow must land in a separate IR/CLI PR before the generators can consume it. Leaving this as a follow-up; the snippet output is unchanged from main.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant