Skip to content

fix(rust): preserve inherited fields in union variants - #17297

Open
fern-api[bot] wants to merge 1 commit into
mainfrom
devin/rust-union-extends-no-inline
Open

fix(rust): preserve inherited fields in union variants#17297
fern-api[bot] wants to merge 1 commit into
mainfrom
devin/rust-union-extends-no-inline

Conversation

@fern-api

@fern-api fern-api Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

Rust discriminated union variants silently dropped every field a variant's type inherited via extends.

Since rust 0.24.0 (#13849), a samePropertiesAsObject variant whose referenced type is used nowhere else gets inlined into the enum variant. The inlining copies referencedType.shape.properties only — shape.extends is never resolved. A wrapper type whose fields all come from extends therefore inlines to nothing:

// before: EntityStreamHeartbeat = { extends: [HeartbeatObject] }, no own properties
#[serde(tag = "event")]
pub enum StreamResponse {
    #[serde(rename = "heartbeat")]
    Heartbeat {},   // <- payload deserializes fine and is thrown away
}

Because the enum is internally tagged, this is worse than a compile error: the events deserialize successfully and the data disappears. Python and TypeScript generate the full field set for the same IR, so Rust was the outlier. Reported by a customer whose SSE stream produced empty Heartbeat/Entity variants.

Fix: exclude object types that declare extends from inlinedUnionVariantTypeIds. Those variants keep the pre-0.24.0 wrapper form, which preserves the whole shape:

Heartbeat {
    #[serde(flatten)]
    data: EntityStreamHeartbeat,
},

Variants whose types have no extends still inline, so the improved union ergonomics from #13849 are unchanged for everything else.

Changes Made

  • generators/rust/model/src/generateModels.ts: require shape.extends.length === 0 before treating a samePropertiesAsObject type as inlinable.
  • Extended the existing union-types model fixture with a PlantEvent union whose variants inherit occurred_at from a base type (one variant with no own properties, one with an extra field), plus snapshots.
  • Regenerated seed/rust-sdk/unions and seed/rust-sdk/unions-with-local-date: FooExtended is no longer inlined and its wrapper struct is emitted again.
  • Rust SDK changelog entry (fix).

Testing

  • Unit tests added/updated — generators/rust/model: 17/17 pass. Reverting the one-line guard fails the new test with exactly the reported symptom: Sprouted {} and Watered { liters } (the inherited occurred_at is missing from both).
  • Seed regeneration — full rust-sdk seed run: 142/142 fixtures pass; the only source diffs are the two unions* fixtures above.
  • Runtime check — serde round-trip on the before/after enum shapes: {"event":"sprouted","occurred_at":"..."} deserializes to Sprouted (field lost, re-serializes to {"event":"sprouted"}) on the old shape, and preserves occurred_at on the new one.

Note: cargo test inside seed/rust-sdk/unions does not compile on main for an unrelated pre-existing reason (types_union_with_duplicative_discriminants.rs has a variant field named type that collides with the serde tag), so the serde round-trip was verified in a standalone crate rather than the fixture.

Independent of the OAuth header fix in #17296.


Open in Devin Review

@fern-api
fern-api Bot requested a review from iamnamananand996 as a code owner July 30, 2026 14:03

@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

Small, well-targeted fix: object types declaring extends are excluded from union-variant inlining, so inherited fields stop vanishing. The guard, fixture, snapshots, and seed regen all line up. Only minor nits around test assertions and changelog framing.

  • 🔵 2 suggestion(s)

Comment on lines +118 to +124
const plantEvent = files.find((file) => file.fileContents.includes("pub enum PlantEvent"));
expect(plantEvent?.fileContents).toContain("data: SproutedEvent,");
expect(plantEvent?.fileContents).toContain("data: WateredEvent,");

// The wrapper structs must still be generated, with their inherited fields.
const sproutedEvent = files.find((file) => file.fileContents.includes("pub struct SproutedEvent"));
expect(sproutedEvent?.fileContents).toContain("plant_event_base_fields: PlantEventBase");

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

files.find(...) can return undefined, and the ?. then hands undefined to toContain, producing a confusing matcher error rather than "file not generated". Assert the files exist first, and while here consider asserting the inherited field on WateredEvent too (that's the variant most likely to regress silently since it has an own property).

Suggested change
const plantEvent = files.find((file) => file.fileContents.includes("pub enum PlantEvent"));
expect(plantEvent?.fileContents).toContain("data: SproutedEvent,");
expect(plantEvent?.fileContents).toContain("data: WateredEvent,");
// The wrapper structs must still be generated, with their inherited fields.
const sproutedEvent = files.find((file) => file.fileContents.includes("pub struct SproutedEvent"));
expect(sproutedEvent?.fileContents).toContain("plant_event_base_fields: PlantEventBase");
const plantEvent = files.find((file) => file.fileContents.includes("pub enum PlantEvent"));
expect(plantEvent).toBeDefined();
expect(plantEvent?.fileContents).toContain("data: SproutedEvent,");
expect(plantEvent?.fileContents).toContain("data: WateredEvent,");
// The wrapper structs must still be generated, with their inherited fields.
const sproutedEvent = files.find((file) => file.fileContents.includes("pub struct SproutedEvent"));
expect(sproutedEvent).toBeDefined();
expect(sproutedEvent?.fileContents).toContain("plant_event_base_fields: PlantEventBase");
const wateredEvent = files.find((file) => file.fileContents.includes("pub struct WateredEvent"));
expect(wateredEvent).toBeDefined();
expect(wateredEvent?.fileContents).toContain("plant_event_base_fields: PlantEventBase");

referenced object type got all of its properties from `extends`, the variant was inlined to
an empty struct, so the payload deserialized successfully but every field was discarded.
Such types are no longer inlined and keep their `#[serde(flatten)]` wrapper.
type: fix

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

Two things:

  1. The seed diff shows this changes generated public API for affected unions (FooExtended { age }FooExtended { data: FooExtended }, and foo_extended(age: i64)foo_extended(data: FooExtended)). Users on 0.24.x with extends-based variants will get compile errors on upgrade — worth calling out explicitly in the summary even if the type stays fix.
  2. The behavior change originates in generators/rust/model. If that generator has its own changes/unreleased directory and is published separately, it needs an entry there as well.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@github-actions

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-30T05:00:19Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
rust-sdk square 213s (n=5) 182s (n=5) 155s -58s (-27.2%)

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-07-30T05:00:19Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-30 14:12 UTC

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.

0 participants