Skip to content
93 changes: 64 additions & 29 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ import type {
GetEnum,
GetEnums,
GetModel,
GetModelDiscriminator,
GetModelField,
GetModelFields,
GetModelFieldType,
GetModels,
GetSubModels,
GetTypeDefField,
GetTypeDefFields,
GetTypeDefFieldType,
GetTypeDefs,
IsDelegateModel,
ModelFieldIsOptional,
NonRelationFields,
ProcedureDef,
Expand Down Expand Up @@ -70,27 +73,19 @@ export type DefaultModelResult<
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
Optional = false,
Array = false,
// Guard: if Model is the generic `string` type (which happens when Schema is the base
// SchemaDef interface), skip all delegate expansion. Checking [string] extends [Model]
// is O(1) and short-circuits before any of the more expensive type computations run,
// keeping the total instantiation count within TypeScript's recursion budget.
IsGenericModel = [string] extends [Model] ? true : false,
> = WrapType<
{
[Key in NonRelationFields<Schema, Model> as ShouldOmitField<Schema, Model, Options, Key, Omit> extends true
? never
: Key]: MapModelFieldType<Schema, Model, Key>;
},
// TODO: revisit how to efficiently implement discriminated sub model types
// 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>;
// },
IsGenericModel extends true
? // generic model — return flat type immediately to avoid expensive recursion
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>
: FlatModelResult<Schema, Model, Omit, Options>,
Optional,
Array
>;
Expand Down Expand Up @@ -135,15 +130,55 @@ type SchemaLevelOmit<
Field extends GetModelFields<Schema, Model>,
> = GetModelField<Schema, Model, Field>['omit'] extends true ? true : false;

// type DelegateUnionResult<
// Schema extends SchemaDef,
// Model extends GetModels<Schema>,
// Options extends QueryOptions<Schema>,
// SubModel extends GetModels<Schema>,
// Omit = undefined,
// > = SubModel extends string // typescript union distribution
// ? DefaultModelResult<Schema, SubModel, Options, Omit> & { [K in GetModelDiscriminator<Schema, Model>]: SubModel } // fixate discriminated field
// : never;
// Flat scalar-only result for a single model (no delegate expansion). Used as the leaf case
// in DelegateUnionResult so that we never call DefaultModelResult from within itself.
type FlatModelResult<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Omit,
Options extends QueryOptions<Schema>,
> = {
[Key in NonRelationFields<Schema, Model> as ShouldOmitField<Schema, Model, Options, Key, Omit> extends true
? never
: Key]: MapModelFieldType<Schema, Model, Key>;
};

// Builds a discriminated union from a delegate model's direct sub-models. Recursion depth
// is tracked via a tuple (each level appends a `0` element); the hard stop at length 10
// ensures the type terminates even for the generic SchemaDef case.
// Each union branch fixes the parent discriminator field to the sub-model name.
// When a sub-model is itself a delegate, we recurse into its own sub-models so all
// concrete leaf types appear in the union, each picking up the accumulated
// discriminator overrides from both levels.
type DelegateUnionResult<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Options extends QueryOptions<Schema>,
SubModel extends GetModels<Schema>,
Omit = undefined,
Depth extends readonly 0[] = [],
> = Depth['length'] extends 10 // hard stop so generic SchemaDef never infinite-loops
? SubModel extends string
? FlatModelResult<Schema, SubModel, Omit, Options> &
{ [K in GetModelDiscriminator<Schema, Model>]: SubModel }
: never
: SubModel extends string // typescript union distribution
? IsDelegateModel<Schema, SubModel> extends true
? // sub-model is itself a delegate — recurse into its own sub-models so all
// concrete leaf types appear in the union, each picking up the accumulated
// discriminator overrides from both levels
DelegateUnionResult<
Schema,
SubModel,
Options,
GetSubModels<Schema, SubModel>,
Omit,
[...Depth, 0]
> & { [K in GetModelDiscriminator<Schema, Model>]: SubModel }
: // leaf model — produce a flat scalar result and fix the discriminator
FlatModelResult<Schema, SubModel, Omit, Options> &
{ [K in GetModelDiscriminator<Schema, Model>]: SubModel }
: never;

type ModelSelectResult<
Schema extends SchemaDef,
Expand Down
40 changes: 20 additions & 20 deletions tests/e2e/orm/schemas/delegate/typecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@ async function find() {
// @ts-expect-error
console.log(r.rating);

// TODO: discriminated sub-model fields
// if (r.assetType === 'Video') {
// // video
// console.log(r.duration);
// // only one choice `RatedVideo`
// console.log(r.rating);
// } else {
// // image
// console.log(r.format);
// }
// discriminated union narrows sub-model fields
if (r.assetType === 'Video') {
// video
console.log(r.duration);
// only one choice `RatedVideo`
console.log(r.rating);
} else {
// image
console.log(r.format);
}

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

// TODO: discriminated sub-model fields
// if (r2.assets[0]?.assetType === 'Video') {
// // video
// console.log(r2.assets[0]?.duration);
// // only one choice `RatedVideo`
// console.log(r2.assets[0]?.rating);
// } else {
// // image
// console.log(r2.assets[0]?.format);
// }
// discriminated union narrows sub-model fields when queried via relation
if (r2.assets[0]?.assetType === 'Video') {
// video
console.log(r2.assets[0]?.duration);
// only one choice `RatedVideo`
console.log(r2.assets[0]?.rating);
} else {
// image
console.log(r2.assets[0]?.format);
}
Comment thread
ymc9 marked this conversation as resolved.

// sub model behavior
const r3 = await client.ratedVideo.findFirstOrThrow();
Expand Down
Loading