Skip to content

Commit 5a60fb2

Browse files
motopodsCopilot
andauthored
fix(orm): type inference for polymorphic models (#2543)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 263074c commit 5a60fb2

File tree

2 files changed

+84
-49
lines changed

2 files changed

+84
-49
lines changed

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

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ import type {
1212
GetEnum,
1313
GetEnums,
1414
GetModel,
15+
GetModelDiscriminator,
1516
GetModelField,
1617
GetModelFields,
1718
GetModelFieldType,
1819
GetModels,
20+
GetSubModels,
1921
GetTypeDefField,
2022
GetTypeDefFields,
2123
GetTypeDefFieldType,
2224
GetTypeDefs,
25+
IsDelegateModel,
2326
ModelFieldIsOptional,
2427
NonRelationFields,
2528
ProcedureDef,
@@ -70,27 +73,19 @@ export type DefaultModelResult<
7073
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
7174
Optional = false,
7275
Array = false,
76+
// Guard: if Model is the generic `string` type (which happens when Schema is the base
77+
// SchemaDef interface), skip all delegate expansion. Checking [string] extends [Model]
78+
// is O(1) and short-circuits before any of the more expensive type computations run,
79+
// keeping the total instantiation count within TypeScript's recursion budget.
80+
IsGenericModel = [string] extends [Model] ? true : false,
7381
> = WrapType<
74-
{
75-
[Key in NonRelationFields<Schema, Model> as ShouldOmitField<Schema, Model, Options, Key, Omit> extends true
76-
? never
77-
: Key]: MapModelFieldType<Schema, Model, Key>;
78-
},
79-
// TODO: revisit how to efficiently implement discriminated sub model types
80-
// IsDelegateModel<Schema, Model> extends true
81-
// ? // delegate model's selection result is a union of all sub-models
82-
// DelegateUnionResult<Schema, Model, Options, GetSubModels<Schema, Model>, Omit>
83-
// : {
84-
// [Key in NonRelationFields<Schema, Model> as ShouldOmitField<
85-
// Schema,
86-
// Model,
87-
// Options,
88-
// Key,
89-
// Omit
90-
// > extends true
91-
// ? never
92-
// : Key]: MapModelFieldType<Schema, Model, Key>;
93-
// },
82+
IsGenericModel extends true
83+
? // generic model — return flat type immediately to avoid expensive recursion
84+
FlatModelResult<Schema, Model, Omit, Options>
85+
: IsDelegateModel<Schema, Model> extends true
86+
? // delegate model's selection result is a union of all sub-models
87+
DelegateUnionResult<Schema, Model, Options, GetSubModels<Schema, Model>, Omit>
88+
: FlatModelResult<Schema, Model, Omit, Options>,
9489
Optional,
9590
Array
9691
>;
@@ -135,15 +130,55 @@ type SchemaLevelOmit<
135130
Field extends GetModelFields<Schema, Model>,
136131
> = GetModelField<Schema, Model, Field>['omit'] extends true ? true : false;
137132

138-
// type DelegateUnionResult<
139-
// Schema extends SchemaDef,
140-
// Model extends GetModels<Schema>,
141-
// Options extends QueryOptions<Schema>,
142-
// SubModel extends GetModels<Schema>,
143-
// Omit = undefined,
144-
// > = SubModel extends string // typescript union distribution
145-
// ? DefaultModelResult<Schema, SubModel, Options, Omit> & { [K in GetModelDiscriminator<Schema, Model>]: SubModel } // fixate discriminated field
146-
// : never;
133+
// Flat scalar-only result for a single model (no delegate expansion). Used as the leaf case
134+
// in DelegateUnionResult so that we never call DefaultModelResult from within itself.
135+
type FlatModelResult<
136+
Schema extends SchemaDef,
137+
Model extends GetModels<Schema>,
138+
Omit,
139+
Options extends QueryOptions<Schema>,
140+
> = {
141+
[Key in NonRelationFields<Schema, Model> as ShouldOmitField<Schema, Model, Options, Key, Omit> extends true
142+
? never
143+
: Key]: MapModelFieldType<Schema, Model, Key>;
144+
};
145+
146+
// Builds a discriminated union from a delegate model's direct sub-models. Recursion depth
147+
// is tracked via a tuple (each level appends a `0` element); the hard stop at length 10
148+
// ensures the type terminates even for the generic SchemaDef case.
149+
// Each union branch fixes the parent discriminator field to the sub-model name.
150+
// When a sub-model is itself a delegate, we recurse into its own sub-models so all
151+
// concrete leaf types appear in the union, each picking up the accumulated
152+
// discriminator overrides from both levels.
153+
type DelegateUnionResult<
154+
Schema extends SchemaDef,
155+
Model extends GetModels<Schema>,
156+
Options extends QueryOptions<Schema>,
157+
SubModel extends GetModels<Schema>,
158+
Omit = undefined,
159+
Depth extends readonly 0[] = [],
160+
> = Depth['length'] extends 10 // hard stop so generic SchemaDef never infinite-loops
161+
? SubModel extends string
162+
? FlatModelResult<Schema, SubModel, Omit, Options> &
163+
{ [K in GetModelDiscriminator<Schema, Model>]: SubModel }
164+
: never
165+
: SubModel extends string // typescript union distribution
166+
? IsDelegateModel<Schema, SubModel> extends true
167+
? // sub-model is itself a delegate — recurse into its own sub-models so all
168+
// concrete leaf types appear in the union, each picking up the accumulated
169+
// discriminator overrides from both levels
170+
DelegateUnionResult<
171+
Schema,
172+
SubModel,
173+
Options,
174+
GetSubModels<Schema, SubModel>,
175+
Omit,
176+
[...Depth, 0]
177+
> & { [K in GetModelDiscriminator<Schema, Model>]: SubModel }
178+
: // leaf model — produce a flat scalar result and fix the discriminator
179+
FlatModelResult<Schema, SubModel, Omit, Options> &
180+
{ [K in GetModelDiscriminator<Schema, Model>]: SubModel }
181+
: never;
147182

148183
type ModelSelectResult<
149184
Schema extends SchemaDef,

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

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ async function find() {
1717
// @ts-expect-error
1818
console.log(r.rating);
1919

20-
// TODO: discriminated sub-model fields
21-
// if (r.assetType === 'Video') {
22-
// // video
23-
// console.log(r.duration);
24-
// // only one choice `RatedVideo`
25-
// console.log(r.rating);
26-
// } else {
27-
// // image
28-
// console.log(r.format);
29-
// }
20+
// discriminated union narrows sub-model fields
21+
if (r.assetType === 'Video') {
22+
// video
23+
console.log(r.duration);
24+
// only one choice `RatedVideo`
25+
console.log(r.rating);
26+
} else {
27+
// image
28+
console.log(r.format);
29+
}
3030

3131
// if fields are explicitly selected, then no sub-model fields are available
3232
const r1 = await client.asset.findFirstOrThrow({
@@ -52,16 +52,16 @@ async function find() {
5252
// @ts-expect-error
5353
console.log(r2.assets[0]?.rating);
5454

55-
// TODO: discriminated sub-model fields
56-
// if (r2.assets[0]?.assetType === 'Video') {
57-
// // video
58-
// console.log(r2.assets[0]?.duration);
59-
// // only one choice `RatedVideo`
60-
// console.log(r2.assets[0]?.rating);
61-
// } else {
62-
// // image
63-
// console.log(r2.assets[0]?.format);
64-
// }
55+
// discriminated union narrows sub-model fields when queried via relation
56+
if (r2.assets[0]?.assetType === 'Video') {
57+
// video
58+
console.log(r2.assets[0]?.duration);
59+
// only one choice `RatedVideo`
60+
console.log(r2.assets[0]?.rating);
61+
} else {
62+
// image
63+
console.log(r2.assets[0]?.format);
64+
}
6565

6666
// sub model behavior
6767
const r3 = await client.ratedVideo.findFirstOrThrow();

0 commit comments

Comments
 (0)