From f5fa06445189d57df6e35b918213dc78692606c3 Mon Sep 17 00:00:00 2001 From: loup Date: Wed, 17 Jun 2026 16:29:17 +0200 Subject: [PATCH 1/2] Fix strict select subset typing under TS6 --- packages/orm/src/client/crud-types.ts | 14 +++++++++++++- tests/e2e/orm/client-api/slicing.test.ts | 4 ++++ tests/e2e/orm/client-api/unsupported.test.ts | 1 + tests/e2e/orm/schemas/typing/typecheck.ts | 10 ++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 1d6542a99..f6cc2767f 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1315,8 +1315,20 @@ export type Subset = { [key in keyof T]: key extends keyof U ? T[key] : never; }; +type NoExtraKeys = T extends object ? T & { [K in Exclude]: never } : T; + +type PreserveNullish = Strict | Extract; + +type StrictArgProperty = Key extends keyof U + ? PreserveNullish, NonNullable>> + : T; + export type SelectSubset = { - [key in keyof T]: key extends keyof U ? T[key] : never; + [key in keyof T]: key extends keyof U + ? key extends 'select' | 'include' | 'omit' + ? StrictArgProperty + : T[key] + : never; } & (T extends { select: any; include: any } ? 'Please either choose `select` or `include`.' : T extends { select: any; omit: any } diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index 4432b58ca..0ec6360ef 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -216,6 +216,7 @@ describe('Query slicing tests', () => { // Profile is excluded, so selecting it should cause type error await expect( db.user.findMany({ + // @ts-expect-error Profile is excluded by slicing select: { id: true, profile: true }, }), ).toBeRejectedByValidation(['"profile"', '"select"']); @@ -223,6 +224,7 @@ describe('Query slicing tests', () => { // Comment is excluded, so selecting it should cause type error await expect( db.post.findMany({ + // @ts-expect-error Comment is excluded by slicing select: { id: true, comments: true }, }), ).toBeRejectedByValidation(['"comments"', '"select"']); @@ -295,6 +297,7 @@ describe('Query slicing tests', () => { // Profile is not included, so selecting it should cause type error await expect( db.user.findMany({ + // @ts-expect-error Profile is not included by slicing select: { id: true, profile: true }, }), ).toBeRejectedByValidation(['"profile"', '"select"']); @@ -302,6 +305,7 @@ describe('Query slicing tests', () => { // Comment is not included, so selecting it should cause type error await expect( db.post.findMany({ + // @ts-expect-error Comment is not included by slicing select: { id: true, comments: true }, }), ).toBeRejectedByValidation(['"comments"', '"select"']); diff --git a/tests/e2e/orm/client-api/unsupported.test.ts b/tests/e2e/orm/client-api/unsupported.test.ts index ad1daba45..da1d6ed69 100644 --- a/tests/e2e/orm/client-api/unsupported.test.ts +++ b/tests/e2e/orm/client-api/unsupported.test.ts @@ -205,6 +205,7 @@ describe('Unsupported field exclusion - Zod runtime validation', () => { it('rejects Unsupported fields in select', async () => { // valid call await db.item.findMany({ select: { id: true, name: true } }); + // @ts-expect-error data (Unsupported) should not be in select await expect(db.item.findMany({ select: { data: true } })).toBeRejectedByValidation(); }); diff --git a/tests/e2e/orm/schemas/typing/typecheck.ts b/tests/e2e/orm/schemas/typing/typecheck.ts index 9f8b2aa86..5262d552b 100644 --- a/tests/e2e/orm/schemas/typing/typecheck.ts +++ b/tests/e2e/orm/schemas/typing/typecheck.ts @@ -74,6 +74,16 @@ async function find() { }); console.log(user2.email); console.log(user2.profile?.age); + // @ts-expect-error name was not selected + console.log(user2.name); + + await client.user.findMany({ + select: { + email: true, + // @ts-expect-error invalid select field + missingField: true, + }, + }); await client.user.findUnique({ // @ts-expect-error expect unique filter From 371687ec08a0532334e72488f6343eef6d71beaf Mon Sep 17 00:00:00 2001 From: loup Date: Thu, 25 Jun 2026 09:44:56 +0200 Subject: [PATCH 2/2] Check nested select subset fields --- packages/orm/src/client/crud-types.ts | 47 ++++++++++++-- tests/e2e/orm/client-api/slicing.test.ts | 1 + tests/e2e/orm/client-api/unsupported.test.ts | 1 + tests/e2e/orm/schemas/typing/typecheck.ts | 64 ++++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index f6cc2767f..8166dc9b8 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1315,19 +1315,58 @@ export type Subset = { [key in keyof T]: key extends keyof U ? T[key] : never; }; -type NoExtraKeys = T extends object ? T & { [K in Exclude]: never } : T; - type PreserveNullish = Strict | Extract; +type NoExtraKeys = Shape extends unknown + ? T extends Shape + ? T extends object + ? T & { [K in Exclude]: never } + : T + : never + : never; + +type StrictExistingArg = PreserveNullish, NonNullable>>; + +type StrictExistingArgKeys = 'where' | 'orderBy' | 'cursor'; + +type StrictQueryArgs = T extends object + ? { + [K in keyof T]: K extends keyof Shape + ? K extends 'select' | 'include' | 'omit' + ? StrictArgProperty + : K extends StrictExistingArgKeys + ? StrictExistingArg + : T[K] + : never; + } + : T; + +type StrictSelectionValue = + T extends object + ? Extract, object> extends infer ObjectShape extends object + ? [ObjectShape] extends [never] + ? T + : PreserveNullish, ObjectShape> & ObjectShape> + : T + : T; + +type StrictSelection = T extends object + ? { + [K in keyof T]: K extends keyof Shape ? StrictSelectionValue : never; + } + : T; + type StrictArgProperty = Key extends keyof U - ? PreserveNullish, NonNullable>> + ? PreserveNullish, NonNullable> & NonNullable> : T; export type SelectSubset = { [key in keyof T]: key extends keyof U ? key extends 'select' | 'include' | 'omit' ? StrictArgProperty - : T[key] + : key extends StrictExistingArgKeys + ? StrictExistingArg + : T[key] : never; } & (T extends { select: any; include: any } ? 'Please either choose `select` or `include`.' diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index 0ec6360ef..b07de0c93 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -379,6 +379,7 @@ describe('Query slicing tests', () => { select: { id: true, posts: { + // @ts-expect-error Comment is excluded by slicing select: { id: true, comments: true }, }, }, diff --git a/tests/e2e/orm/client-api/unsupported.test.ts b/tests/e2e/orm/client-api/unsupported.test.ts index da1d6ed69..933e79c6b 100644 --- a/tests/e2e/orm/client-api/unsupported.test.ts +++ b/tests/e2e/orm/client-api/unsupported.test.ts @@ -219,6 +219,7 @@ describe('Unsupported field exclusion - Zod runtime validation', () => { it('rejects Unsupported fields in orderBy', async () => { // valid call await db.item.findMany({ orderBy: { name: 'asc' } }); + // @ts-expect-error data (Unsupported) should not be in orderBy await expect(db.item.findMany({ orderBy: { data: 'asc' } })).toBeRejectedByValidation(); }); diff --git a/tests/e2e/orm/schemas/typing/typecheck.ts b/tests/e2e/orm/schemas/typing/typecheck.ts index 5262d552b..13a8dc2b5 100644 --- a/tests/e2e/orm/schemas/typing/typecheck.ts +++ b/tests/e2e/orm/schemas/typing/typecheck.ts @@ -85,6 +85,43 @@ async function find() { }, }); + await client.user.findMany({ + select: { + posts: { + select: { + title: true, + // @ts-expect-error invalid nested select field + missingField: true, + }, + }, + }, + }); + + await client.user.findMany({ + include: { + posts: { + include: { + meta: { + select: { + reviewed: true, + // @ts-expect-error invalid deeply nested select field + missingField: true, + }, + }, + }, + }, + }, + }); + + await client.user.findMany({ + select: { + posts: { + // @ts-expect-error invalid nested omit field + omit: { missingField: true }, + }, + }, + }); + await client.user.findUnique({ // @ts-expect-error expect unique filter where: { name: 'Alex' }, @@ -101,16 +138,43 @@ async function find() { where: { status: { hasEvery: [Status.ACTIVE] } }, }); + await client.user.findMany({ + where: { + email: 'alex@zenstack.dev', + // @ts-expect-error invalid where field + missingField: 'value', + }, + }); + await client.user.findMany({ skip: 1, take: 1, orderBy: { email: 'asc', name: 'desc', + // @ts-expect-error invalid orderBy field + missingField: 'asc', }, cursor: { id: 1 }, }); + await client.user.findMany({ + select: { + posts: { + where: { + title: 'Hello', + // @ts-expect-error invalid nested where field + missingField: 'value', + }, + orderBy: { + title: 'asc', + // @ts-expect-error invalid nested orderBy field + missingField: 'asc', + }, + }, + }, + }); + const user3 = await client.user.findFirstOrThrow({ select: { _count: true,