Skip to content

fix(orm): type inference for polymorphic models#2543

Merged
ymc9 merged 8 commits into
zenstackhq:devfrom
motopods:type-inference-for-polymorphic-models
Apr 3, 2026
Merged

fix(orm): type inference for polymorphic models#2543
ymc9 merged 8 commits into
zenstackhq:devfrom
motopods:type-inference-for-polymorphic-models

Conversation

@motopods
Copy link
Copy Markdown
Contributor

@motopods motopods commented Apr 2, 2026

Summary

Querying a delegate (polymorphic base) model such as db.asset.findMany() now returns a proper TypeScript discriminated union instead of the flat base type.

Before: findFirstOrThrow() returned Asset — accessing r.duration was always a type error, and narrowing by r.assetType had no effect on the type.

After: findFirstOrThrow() returns:

| (ImageFields   & { assetType: 'Image' })
| (RatedVideoFields & { assetType: 'Video'; videoType: 'RatedVideo' })

TypeScript can now narrow the result via the discriminator field:

const r = await db.asset.findFirstOrThrow();
if (r.assetType === 'Video') {
    console.log(r.duration); // ✅ Video-specific field
    console.log(r.rating);   // ✅ RatedVideo-specific field (only submodel)
} else {
    console.log(r.format);   // ✅ Image-specific field
}

Multi-level delegation (Asset → Video → RatedVideo) is fully expanded so every concrete leaf model appears in the union. The select path continues to return a flat result as before (explicitly selected fields remove polymorphic information).

Changes

packages/orm/src/client/crud-types.ts

  • Added IsDelegateModel, GetModelDiscriminator, GetSubModels to schema imports.
  • Added FlatModelResult helper type — the original scalar-field object expression extracted into a named alias for the leaf case.
  • Added DelegateUnionResult<Schema, Model, Options, SubModel, Omit, _Depth>:
    • Distributes TypeScript's union over each direct sub-model.
    • When a sub-model is itself a delegate, recurses into its sub-models so all concrete leaf types appear with all ancestor discriminator fields fixed to literal values.
    • Uses a tuple-based depth counter (hard stop at 10) as an additional safety net.
  • Modified DefaultModelResult to use DelegateUnionResult for delegate models.
    • Added _IsGenericModel default type parameter (checks [string] extends [Model]) that short-circuits the entire delegate expansion when TypeScript is evaluating ClientContract<SchemaDef> (the generic base interface). This keeps the type instantiation count bounded (~1.9M vs 1.6M baseline, well below TypeScript's recursion limit).

tests/e2e/orm/schemas/delegate/typecheck.ts

  • Uncommented the two discriminated-union narrowing test blocks (for findFirstOrThrow and the include: { assets: true } relation path).

Fix #2352

Summary by CodeRabbit

  • Improvements
    • Enhanced ORM handling for polymorphic models, yielding more reliable discriminated-union behavior and safer, narrower access to variant-specific fields (including safeguards for very deep model nesting).
  • Tests
    • Enabled e2e type checks that validate discriminant-based narrowing and correct access to variant fields in queries and nested relations.

Copilot AI and others added 6 commits April 2, 2026 05:00
…-content

feat(orm): implement discriminated union return types for delegate (polymorphic) models
…rence-for-content

Revert "feat(orm): implement discriminated union return types for delegate (polymorphic) models"
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

Adds discriminant-aware type logic to ORM client types to produce discriminated unions for delegated models, including a recursion-depth guard and a generic-model switch; activates discriminant-based narrowing examples in an end-to-end typecheck test.

Changes

Cohort / File(s) Summary
Type System Enhancements
packages/orm/src/client/crud-types.ts
Introduced new generics and branching for DefaultModelResult (IsGenericModel), added FlatModelResult, DelegateUnionResult with a recursion Depth guard (stop at 10), and wired discriminator/submodel helpers (GetModelDiscriminator, GetSubModels, IsDelegateModel) to construct discriminated unions for delegate models.
Discriminated Union Tests
tests/e2e/orm/schemas/delegate/typecheck.ts
Replaced commented TODOs with active runtime control-flow checks using the assetType discriminant in both direct find() results and relation-query paths to exercise narrowing for delegate submodels.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇
I hop through types with twitching nose,
I stitch the branches where each flag shows,
Ten hops and then a gentle stop,
Each variant finds its rightful spot—
Hooray, the union tale unfolds!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(orm): type inference for polymorphic models' clearly and concisely summarizes the main change—fixing type inference for polymorphic/delegate models.
Linked Issues check ✅ Passed The PR directly addresses issue #2352 by implementing discriminated unions for delegate models with literal discriminator fields, enabling TypeScript type narrowing and access to subtype-specific fields.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing type inference for polymorphic models: updating type definitions in crud-types.ts and enabling discriminated-union narrowing tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@motopods motopods marked this pull request as ready for review April 2, 2026 09:27
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/orm/src/client/crud-types.ts (1)

81-88: Consider reusing FlatModelResult to reduce duplication.

Lines 84 and 88 contain the same mapped type expression that's also defined as FlatModelResult at lines 140-144. Using FlatModelResult<Schema, Model, Omit, Options> here would improve maintainability.

♻️ Suggested refactor
 > = WrapType<
     _IsGenericModel extends true
-        ? // generic model — return flat type immediately to avoid expensive recursion
-          { [Key in NonRelationFields<Schema, Model> as ShouldOmitField<Schema, Model, Options, Key, Omit> extends true ? never : Key]: MapModelFieldType<Schema, Model, Key> }
+        ? // generic model — return flat type immediately
+          FlatModelResult<Schema, Model, Omit, Options>
         : IsDelegateModel<Schema, Model> extends true
           ? // delegate model's selection result is a union of all sub-models
             DelegateUnionResult<Schema, Model, Options, GetSubModels<Schema, Model>, Omit>
-          : { [Key in NonRelationFields<Schema, Model> as ShouldOmitField<Schema, Model, Options, Key, Omit> extends true ? never : Key]: MapModelFieldType<Schema, Model, Key> },
+          : FlatModelResult<Schema, Model, Omit, Options>,
     Optional,
     Array
 >;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/crud-types.ts` around lines 81 - 88, The mapped type
used in the non-delegate and generic branches is duplicated; replace that inline
mapped type with the existing FlatModelResult generic to remove duplication. In
the WrapType conditional where _IsGenericModel and the final else currently
expand to "{ [Key in NonRelationFields<Schema, Model> as ShouldOmitField<Schema,
Model, Options, Key, Omit> extends true ? never : Key]:
MapModelFieldType<Schema, Model, Key> }", swap those expansions to
FlatModelResult<Schema, Model, Omit, Options> (keeping the DelegateUnionResult
branch unchanged), ensuring you reference the same type parameters (Schema,
Model, Omit, Options) and preserve behavior for IsDelegateModel and
DelegateUnionResult/GetSubModels.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/orm/src/client/crud-types.ts`:
- Around line 81-88: The mapped type used in the non-delegate and generic
branches is duplicated; replace that inline mapped type with the existing
FlatModelResult generic to remove duplication. In the WrapType conditional where
_IsGenericModel and the final else currently expand to "{ [Key in
NonRelationFields<Schema, Model> as ShouldOmitField<Schema, Model, Options, Key,
Omit> extends true ? never : Key]: MapModelFieldType<Schema, Model, Key> }",
swap those expansions to FlatModelResult<Schema, Model, Omit, Options> (keeping
the DelegateUnionResult branch unchanged), ensuring you reference the same type
parameters (Schema, Model, Omit, Options) and preserve behavior for
IsDelegateModel and DelegateUnionResult/GetSubModels.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c33bbb8e-9b19-46ca-9eb2-1525981d53b2

📥 Commits

Reviewing files that changed from the base of the PR and between 28ae08d and c990aa4.

📒 Files selected for processing (2)
  • packages/orm/src/client/crud-types.ts
  • tests/e2e/orm/schemas/delegate/typecheck.ts

@ymc9
Copy link
Copy Markdown
Member

ymc9 commented Apr 2, 2026

@copilot review

@ymc9 ymc9 changed the title Type inference for polymorphic models fix(orm): type inference for polymorphic models Apr 2, 2026
@ymc9 ymc9 requested a review from Copilot April 2, 2026 17:21
ymc9
ymc9 previously approved these changes Apr 2, 2026
Copy link
Copy Markdown
Member

@ymc9 ymc9 left a comment

Choose a reason for hiding this comment

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

Thanks for the fix @motopods ! LGTM and the depth limit added extra safety.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Improves ORM TypeScript result typing for delegated (polymorphic) models so delegate queries return a discriminated union of all concrete leaf models (including multi-level delegation), enabling proper narrowing via discriminator fields.

Changes:

  • Update DefaultModelResult to expand delegate models into a discriminated union (with a generic-model guard to limit instantiation cost).
  • Introduce FlatModelResult and DelegateUnionResult to build recursive leaf unions with fixed discriminator literals.
  • Re-enable/extend e2e typecheck assertions verifying discriminant-based narrowing for direct and relation-based queries.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
packages/orm/src/client/crud-types.ts Adds delegate-union expansion logic for query result types, including multi-level delegation support and a guard for generic schema evaluation.
tests/e2e/orm/schemas/delegate/typecheck.ts Activates discriminated-union narrowing checks for delegate queries and relation includes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/orm/src/client/crud-types.ts Outdated
Comment thread tests/e2e/orm/schemas/delegate/typecheck.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/orm/src/client/crud-types.ts (1)

87-87: Consider defensive distributive Partial wrapper for union discriminator preservation, but this is only necessary if authType is assigned a delegate model.

AuthModelType wraps Partial<DefaultModelResult<...>>. When DefaultModelResult returns a discriminated union (DelegateUnionResult), TypeScript distributes Partial over the union, making each discriminator field optional and weakening narrowing. However, in practice, authType in packages/orm/src/client/contract.ts:973-980 is typically a scalar or simple model—rarely a delegate model—so this scenario is uncommon.

If delegate models become valid for authType, apply the distributive pattern at lines 965-971 in packages/orm/src/client/contract.ts:

type AuthModelType<Schema extends SchemaDef, Model extends GetModels<Schema>> =
    Model extends unknown
        ? Partial<DefaultModelResult<Schema, Model>> & { ... }
        : never;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/crud-types.ts` at line 87, AuthModelType currently
wraps Partial<DefaultModelResult<...>> which lets TypeScript distribute Partial
over a discriminated union (DelegateUnionResult) and weaken discriminators; to
prevent this when DefaultModelResult may be a delegate-model union, change the
AuthModelType definition (the type that composes
Partial<DefaultModelResult<...>> for authType) into a distributive conditional
form so Partial is applied per-variant rather than distributed—i.e., make
AuthModelType conditional on Model (Model extends unknown ? ... : never) and
place the Partial<DefaultModelResult<...>> & { ... } inside the true branch so
discriminators are preserved if DefaultModelResult returns
DelegateUnionResult/union types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/orm/src/client/crud-types.ts`:
- Line 87: AuthModelType currently wraps Partial<DefaultModelResult<...>> which
lets TypeScript distribute Partial over a discriminated union
(DelegateUnionResult) and weaken discriminators; to prevent this when
DefaultModelResult may be a delegate-model union, change the AuthModelType
definition (the type that composes Partial<DefaultModelResult<...>> for
authType) into a distributive conditional form so Partial is applied per-variant
rather than distributed—i.e., make AuthModelType conditional on Model (Model
extends unknown ? ... : never) and place the Partial<DefaultModelResult<...>> &
{ ... } inside the true branch so discriminators are preserved if
DefaultModelResult returns DelegateUnionResult/union types.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3f827469-e652-4d9c-93c5-2ee2997953b8

📥 Commits

Reviewing files that changed from the base of the PR and between f453895 and a5135ea.

📒 Files selected for processing (1)
  • packages/orm/src/client/crud-types.ts

@ymc9 ymc9 merged commit 5a60fb2 into zenstackhq:dev Apr 3, 2026
12 checks passed
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.

Regression: ZenStack v3 does not properly generate TypeScript string literal discriminators for delegated models

4 participants