From add290302141b9f400feb88ba4ecbf982134d713 Mon Sep 17 00:00:00 2001 From: loup topalian Date: Wed, 15 Jul 2026 21:24:20 +0200 Subject: [PATCH 1/3] fix(orm): add opt-in exact query argument typing --- packages/orm/src/client/contract.ts | 19 ++- packages/orm/src/client/crud-types.ts | 28 ++- packages/orm/src/client/options.ts | 13 +- packages/orm/src/utils/type-utils.ts | 29 ++++ tests/e2e/orm/schemas/typing/typecheck.ts | 199 +++++++++++++++++++++- 5 files changed, 269 insertions(+), 19 deletions(-) diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index 7b5541a54..17ec78fc2 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -393,7 +393,8 @@ export type AllModelOperations< >( args?: SelectSubset< T, - CrudArgsType + CrudArgsType, + Options >, ): ZenStackPromise>; @@ -422,7 +423,11 @@ export type AllModelOperations< updateManyAndReturn< T extends CrudArgsType, >( - args: Subset>, + args: Subset< + T, + CrudArgsType, + Options + >, ): ZenStackPromise>; }); @@ -620,7 +625,7 @@ type CommonModelOperations< * ``` */ create>( - args: SelectSubset>, + args: SelectSubset, Options>, ): ZenStackPromise>; /** @@ -649,7 +654,7 @@ type CommonModelOperations< * ``` */ createMany>( - args?: SelectSubset>, + args?: SelectSubset, Options>, ): ZenStackPromise>; /** @@ -770,7 +775,7 @@ type CommonModelOperations< * ``` */ update>( - args: SelectSubset>, + args: SelectSubset, Options>, ): ZenStackPromise>; /** @@ -794,7 +799,7 @@ type CommonModelOperations< * }); */ updateMany>( - args: Subset>, + args: Subset, Options>, ): ZenStackPromise>; /** @@ -818,7 +823,7 @@ type CommonModelOperations< * ``` */ upsert>( - args: SelectSubset>, + args: SelectSubset, Options>, ): ZenStackPromise>; /** diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 1d6542a99..dbe8654a2 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -41,6 +41,7 @@ import type { MapBaseType, MaybePromise, NonEmptyArray, + NoExtraProperties, NullableIf, Optional, OrArray, @@ -1311,17 +1312,22 @@ export type IncludeInput< : {} : {}); -export type Subset = { +type StrictArgs = Options extends { typing: { exactQueryArgs: true } } + ? NoExtraProperties + : unknown; + +export type Subset = { [key in keyof T]: key extends keyof U ? T[key] : never; -}; +} & StrictArgs; -export type SelectSubset = { +export type SelectSubset = { [key in keyof T]: key extends keyof U ? T[key] : never; -} & (T extends { select: any; include: any } - ? 'Please either choose `select` or `include`.' - : T extends { select: any; omit: any } - ? 'Please either choose `select` or `omit`.' - : {}); +} & StrictArgs & + (T extends { select: any; include: any } + ? 'Please either choose `select` or `include`.' + : T extends { select: any; omit: any } + ? 'Please either choose `select` or `omit`.' + : {}); type ToManyRelationFilter< in out Schema extends SchemaDef, @@ -1453,7 +1459,11 @@ type OppositeRelationAndFK< //#region Find args -type FilterArgs, in out Options extends QueryOptions> = { +type FilterArgs< + in out Schema extends SchemaDef, + in out Model extends GetModels, + in out Options extends QueryOptions, +> = { /** * Filter conditions */ diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index d70cc6120..4ab02fc1a 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -139,6 +139,17 @@ type FieldSlicingOptions = { * Partial ORM client options that defines customizable behaviors. */ export type QueryOptions = { + /** + * Type-checking options for query arguments. + */ + typing?: { + /** + * Recursively rejects unknown properties in query arguments. This is opt-in because the + * additional precision requires more work from the TypeScript checker. + */ + exactQueryArgs?: boolean; + }; + /** * Options for omitting fields in ORM query results. */ @@ -158,7 +169,7 @@ export type QueryOptions = { /** * Projects a (typically inferred) client options type down to only the members that influence - * ORM typing - the {@link QueryOptions} fields (`omit`, `allowQueryTimeOmitOverride`, `slicing`). + * ORM typing - the {@link QueryOptions} fields (`typing`, `omit`, `allowQueryTimeOmitOverride`, `slicing`). * * The full options object inferred at `new ZenStackClient(...)` carries heavy function types for * `computedFields` and `procedures`. Those are never read by the model/operation types, but if the diff --git a/packages/orm/src/utils/type-utils.ts b/packages/orm/src/utils/type-utils.ts index bd020ebae..8f8bae8d8 100644 --- a/packages/orm/src/utils/type-utils.ts +++ b/packages/orm/src/utils/type-utils.ts @@ -90,3 +90,32 @@ export type UnwrapTuplePromises = { }; export type Exact = T extends Shape ? (Exclude extends never ? T : never) : never; + +type _ExactLeaf = Date | Function | Decimal | Uint8Array; +type _ObjectKeys = T extends object ? keyof T : never; +type _ObjectValue = T extends object ? (K extends keyof T ? T[K] : never) : never; +type _ArrayItem = T extends readonly (infer Item)[] ? Item : never; + +type _NoExtraObject> = [Keys] extends [never] + ? never + : string extends Keys + ? unknown + : { + [K in keyof T]: K extends Keys ? NoExtraProperties> : never; + }; + +/** + * Rejects unknown keys by walking only the finite inferred input. Expected unions are distributed + * only when looking up the current property, avoiding reconstruction of the input per union branch. + */ +export type NoExtraProperties = unknown extends Shape + ? unknown + : T extends _ExactLeaf + ? unknown + : T extends readonly (infer Item)[] + ? [_ArrayItem] extends [never] + ? never + : NoExtraProperties>[] + : T extends object + ? _NoExtraObject + : unknown; diff --git a/tests/e2e/orm/schemas/typing/typecheck.ts b/tests/e2e/orm/schemas/typing/typecheck.ts index 9f8b2aa86..9674e10b3 100644 --- a/tests/e2e/orm/schemas/typing/typecheck.ts +++ b/tests/e2e/orm/schemas/typing/typecheck.ts @@ -17,6 +17,20 @@ const client = new ZenStackClient(schema, { }, }); +const strictClient = new ZenStackClient(schema, { + dialect: new SqliteDialect({ database: new SQLite('./zenstack/test.db') }), + computedFields: { + user: { + postCount: (eb) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', 'id') + .select(({ fn }) => fn.countAll().as('postCount')), + }, + }, + typing: { exactQueryArgs: true }, +}); + async function main() { await find(); await create(); @@ -31,6 +45,60 @@ async function main() { } async function find() { + await client.user.findMany({ + where: { + posts: { + some: { + // @ts-expect-error unknown nested where field + missingField: true, + }, + }, + }, + }); + + await client.user.findMany({ + select: { + posts: { + select: { + // @ts-expect-error unknown deeply nested select field + missingField: true, + }, + }, + }, + }); + + await client.user.findMany({ + include: { + posts: { + where: { + // @ts-expect-error unknown where field nested in include + missingField: true, + }, + }, + }, + }); + + await client.user.findMany({ + select: { + _count: { + select: { + // @ts-expect-error unknown nested count field + missingField: true, + }, + }, + }, + }); + + const invalidArgs = { + where: { + posts: { + some: { missingField: true }, + }, + }, + }; + // @ts-expect-error hoisted arguments are recursively checked too + await client.user.findMany(invalidArgs); + const user1 = await client.user.findFirst({ where: { name: 'Alex', @@ -223,6 +291,96 @@ async function find() { } async function create() { + // Exact nested argument checking stays opt-in for compatibility. + await client.user.create({ + data: { + name: 'Alex', + email: 'alex@zenstack.dev', + profile: { + create: { + age: 20, + missingField: true, + }, + }, + }, + }); + + await strictClient.user.create({ + data: { + name: 'Alex', + email: 'alex@zenstack.dev', + profile: { + create: { + age: 20, + // @ts-expect-error unknown nested create field in exact mode + missingField: true, + }, + }, + }, + }); + + const invalidNestedCreateArgs = { + data: { + name: 'Alex', + email: 'alex@zenstack.dev', + profile: { create: { age: 20, missingField: true } }, + }, + }; + // @ts-expect-error hoisted nested write arguments are checked in exact mode + await strictClient.user.create(invalidNestedCreateArgs); + + const invalidNestedCreateArrayArgs = { + data: { + title: 'post', + content: 'content', + author: { connect: { id: 1 } }, + tags: { + create: [{ name: 'valid' }, { name: 'invalid', missingField: true }], + }, + }, + }; + // @ts-expect-error exactness recurses into array elements + await strictClient.post.create(invalidNestedCreateArrayArgs); + + await strictClient.profile.create({ + data: { age: 20, user: { connect: { id: 1 } } }, + }); + + await strictClient.profile.create({ + data: { age: 20, userId: 1 }, + }); + + await strictClient.profile.create({ + // @ts-expect-error checked and unchecked create branches cannot be mixed + data: { + age: 20, + userId: 1, + user: { connect: { id: 1 } }, + }, + }); + + await strictClient.user.create({ + data: { + name: 'JSON user', + email: 'json@example.com', + createdAt: new Date(), + identity: { providers: [{ id: 'github', name: 'GitHub' }] }, + }, + }); + + await strictClient.profile.create({ + data: { + age: 20, + user: { + connect: { + id: 1, + // @ts-expect-error unknown nested connect field + missingField: true, + }, + }, + }, + }); + await client.user.create({ // @ts-expect-error email is required data: { name: 'Alex' }, @@ -372,7 +530,7 @@ async function create() { tags: { create: [{ name: 'tag2' }, { name: 'tag3' }] }, }, }); - await client.tag.create({ + await strictClient.tag.create({ data: { name: 'tag4', posts: { @@ -382,6 +540,8 @@ async function create() { title: 'Hello World', content: 'This is a test post', author: { connect: { id: 1 } }, + // @ts-expect-error unknown nested connectOrCreate field + missingField: true, }, }, }, @@ -406,7 +566,22 @@ async function update() { }, }); - await client.user.update({ + const invalidMutationWhereArgs = { + where: { id: 1, missingField: true }, + data: { name: 'Alex' }, + }; + // @ts-expect-error exact mode checks hoisted mutation where arguments + await strictClient.user.update(invalidMutationWhereArgs); + + const invalidMutationSelectArgs = { + where: { id: 1 }, + data: { name: 'Alex' }, + select: { posts: { select: { missingField: true } } }, + }; + // @ts-expect-error exact mode checks hoisted mutation relation selects + await strictClient.user.update(invalidMutationSelectArgs); + + await strictClient.user.update({ where: { id: 1 }, data: { posts: { @@ -440,6 +615,8 @@ async function update() { data: { title: 'Hello World', content: 'This is a test post', + // @ts-expect-error unknown deeply nested update field + missingField: true, }, }, upsert: { @@ -451,6 +628,8 @@ async function update() { update: { title: 'Hello World', content: 'This is a test post', + // @ts-expect-error unknown deeply nested upsert field + missingField: true, }, }, updateMany: { @@ -464,6 +643,22 @@ async function update() { }, }); + await strictClient.user.upsert({ + where: { id: 1 }, + create: { + name: 'Alex', + email: 'alex@zenstack.dev', + profile: { + create: { + age: 20, + // @ts-expect-error unknown field in a top-level upsert create payload + missingField: true, + }, + }, + }, + update: { name: 'Alex' }, + }); + await client.user.update({ where: { id: 1 }, data: { From bb176f3f7fb78e275a0348a1f95fd70cc4403ccd Mon Sep 17 00:00:00 2001 From: loup topalian Date: Sun, 19 Jul 2026 23:05:06 +0200 Subject: [PATCH 2/3] fix(orm): apply exact query args to all operations --- packages/orm/src/client/contract.ts | 30 ++++++++----- tests/e2e/orm/schemas/typing/typecheck.ts | 52 +++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index 17ec78fc2..7caf801da 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -529,7 +529,7 @@ type CommonModelOperations< * ``` */ findMany>( - args?: SelectSubset>, + args?: SelectSubset, Options>, ): ZenStackPromise>; /** @@ -539,7 +539,7 @@ type CommonModelOperations< * @see {@link findMany} */ findUnique>( - args: SelectSubset>, + args: SelectSubset, Options>, ): ZenStackPromise>; /** @@ -549,7 +549,11 @@ type CommonModelOperations< * @see {@link findMany} */ findUniqueOrThrow>( - args: SelectSubset>, + args: SelectSubset< + T, + CrudArgsType, + Options + >, ): ZenStackPromise>; /** @@ -559,7 +563,7 @@ type CommonModelOperations< * @see {@link findMany} */ findFirst>( - args?: SelectSubset>, + args?: SelectSubset, Options>, ): ZenStackPromise>; /** @@ -569,7 +573,11 @@ type CommonModelOperations< * @see {@link findMany} */ findFirstOrThrow>( - args?: SelectSubset>, + args?: SelectSubset< + T, + CrudArgsType, + Options + >, ): ZenStackPromise>; /** @@ -846,7 +854,7 @@ type CommonModelOperations< * ``` */ delete>( - args: SelectSubset>, + args: SelectSubset, Options>, ): ZenStackPromise>; /** @@ -869,7 +877,7 @@ type CommonModelOperations< * ``` */ deleteMany>( - args?: Subset>, + args?: Subset, Options>, ): ZenStackPromise>; /** @@ -891,7 +899,7 @@ type CommonModelOperations< * }); // result: `{ _all: number, email: number }` */ count>( - args?: Subset>, + args?: Subset, Options>, ): ZenStackPromise>>; /** @@ -912,7 +920,7 @@ type CommonModelOperations< * }); // result: `{ _count: number, _avg: { age: number }, ... }` */ aggregate>( - args: Subset>, + args: Subset, Options>, ): ZenStackPromise>>; /** @@ -949,7 +957,7 @@ type CommonModelOperations< * }); */ groupBy>( - args: Subset>, + args: Subset, Options>, ): ZenStackPromise>>; /** @@ -970,7 +978,7 @@ type CommonModelOperations< * }); // result: `boolean` */ exists>( - args?: Subset>, + args?: Subset, Options>, ): ZenStackPromise>; }; diff --git a/tests/e2e/orm/schemas/typing/typecheck.ts b/tests/e2e/orm/schemas/typing/typecheck.ts index 9674e10b3..2ec58d2cd 100644 --- a/tests/e2e/orm/schemas/typing/typecheck.ts +++ b/tests/e2e/orm/schemas/typing/typecheck.ts @@ -99,6 +99,25 @@ async function find() { // @ts-expect-error hoisted arguments are recursively checked too await client.user.findMany(invalidArgs); + const invalidExactReadArgs = { + where: { + posts: { + some: { title: 'post', missingField: true }, + }, + }, + select: { + posts: { + where: { title: 'post', missingField: true }, + orderBy: { title: 'asc' as const, missingField: 'asc' as const }, + cursor: { id: 1, missingField: true }, + select: { id: true, missingField: true }, + }, + }, + }; + await client.user.findMany(invalidExactReadArgs); + // @ts-expect-error exact mode recursively checks all supplied read arguments + await strictClient.user.findMany(invalidExactReadArgs); + const user1 = await client.user.findFirst({ where: { name: 'Alex', @@ -752,6 +771,16 @@ async function update() { } async function del() { + const invalidDeleteArgs = { where: { id: 1, missingField: true }, select: { id: true } }; + await client.user.delete(invalidDeleteArgs); + // @ts-expect-error exact mode checks delete arguments + await strictClient.user.delete(invalidDeleteArgs); + + const invalidDeleteManyArgs = { where: { name: 'Alex', missingField: true } }; + await client.user.deleteMany(invalidDeleteManyArgs); + // @ts-expect-error exact mode checks deleteMany arguments + await strictClient.user.deleteMany(invalidDeleteManyArgs); + // @ts-expect-error where is required await client.user.delete({}); @@ -768,6 +797,16 @@ async function del() { } async function count() { + const invalidCountArgs = { where: { name: 'Alex', missingField: true } }; + await client.user.count(invalidCountArgs); + // @ts-expect-error exact mode checks count arguments + await strictClient.user.count(invalidCountArgs); + + const invalidExistsArgs = { where: { id: 1, missingField: true } }; + await client.user.exists(invalidExistsArgs); + // @ts-expect-error exact mode checks exists arguments + await strictClient.user.exists(invalidExistsArgs); + await client.user.count(); await client.user.count({ where: { @@ -786,6 +825,11 @@ async function count() { } async function aggregate() { + const invalidAggregateArgs = { _avg: { age: true as const, missingField: true } }; + await client.profile.aggregate(invalidAggregateArgs); + // @ts-expect-error exact mode checks aggregate arguments + await strictClient.profile.aggregate(invalidAggregateArgs); + const r = await client.profile.aggregate({ _count: true, _avg: { age: true }, @@ -812,6 +856,14 @@ async function aggregate() { } async function groupBy() { + const invalidGroupByArgs = { + by: 'regionCountry' as const, + _sum: { age: true as const, missingField: true }, + }; + await client.profile.groupBy(invalidGroupByArgs); + // @ts-expect-error exact mode checks groupBy arguments + await strictClient.profile.groupBy(invalidGroupByArgs); + const r = await client.profile.groupBy({ by: ['regionCountry', 'regionCity'], _count: true, From de5d2c3cf72822894f162b2aca1d5b3bb10216d2 Mon Sep 17 00:00:00 2001 From: loup topalian Date: Sun, 19 Jul 2026 23:06:59 +0200 Subject: [PATCH 3/3] fix(orm): preserve empty exact argument objects --- packages/orm/src/utils/type-utils.ts | 12 +++++------- tests/e2e/orm/schemas/typing/typecheck.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/orm/src/utils/type-utils.ts b/packages/orm/src/utils/type-utils.ts index 8f8bae8d8..3df3290df 100644 --- a/packages/orm/src/utils/type-utils.ts +++ b/packages/orm/src/utils/type-utils.ts @@ -96,13 +96,11 @@ type _ObjectKeys = T extends object ? keyof T : never; type _ObjectValue = T extends object ? (K extends keyof T ? T[K] : never) : never; type _ArrayItem = T extends readonly (infer Item)[] ? Item : never; -type _NoExtraObject> = [Keys] extends [never] - ? never - : string extends Keys - ? unknown - : { - [K in keyof T]: K extends Keys ? NoExtraProperties> : never; - }; +type _NoExtraObject> = string extends Keys + ? unknown + : { + [K in keyof T]: K extends Keys ? NoExtraProperties> : never; + }; /** * Rejects unknown keys by walking only the finite inferred input. Expected unions are distributed diff --git a/tests/e2e/orm/schemas/typing/typecheck.ts b/tests/e2e/orm/schemas/typing/typecheck.ts index 2ec58d2cd..6c26cec98 100644 --- a/tests/e2e/orm/schemas/typing/typecheck.ts +++ b/tests/e2e/orm/schemas/typing/typecheck.ts @@ -1,4 +1,4 @@ -import { ZenStackClient } from '@zenstackhq/orm'; +import { ZenStackClient, type Subset } from '@zenstackhq/orm'; import SQLite from 'better-sqlite3'; import { SqliteDialect } from 'kysely'; import { Role, Status, type Identity, type IdentityProvider } from './models'; @@ -912,6 +912,15 @@ function enums() { } function typeDefs() { + const emptyExactObject: Subset<{}, {}, { typing: { exactQueryArgs: true } }> = {}; + console.log(emptyExactObject); + + const invalidEmptyExactObject: Subset<{ extra: true }, {}, { typing: { exactQueryArgs: true } }> = { + // @ts-expect-error finite empty shapes still reject supplied properties + extra: true, + }; + console.log(invalidEmptyExactObject); + const identityProvider: IdentityProvider = { id: '123', name: 'GitHub',