Skip to content

feat(zod): add Prisma-style select/include/omit options to makeModelSchema#2503

Open
marcsigmund wants to merge 1 commit intozenstackhq:devfrom
marcsigmund:feat/zod-make-model-schema-options
Open

feat(zod): add Prisma-style select/include/omit options to makeModelSchema#2503
marcsigmund wants to merge 1 commit intozenstackhq:devfrom
marcsigmund:feat/zod-make-model-schema-options

Conversation

@marcsigmund
Copy link

@marcsigmund marcsigmund commented Mar 20, 2026

feat(zod): add Prisma-style select/include/omit options to makeModelSchema

Add support for field selection and relation inclusion in Zod schema generation through new options parameter. The makeModelSchema method now accepts optional select, include, and omit options to control which fields appear in the generated schema.

Key changes:

  • Add overloaded signatures to makeModelSchema with type-safe options
  • Implement buildFieldsWithOptions to handle field filtering and relation inclusion
  • Add GetModelSchemaShapeWithOptions type to compute resulting shape based on options
  • Skip model-level @@Validate on select path to avoid false negatives when referenced fields are not part of the selection
  • Add comprehensive type utilities for field selection and relation handling

This enables more flexible schema generation for partial model validation and nested relation schemas while maintaining full type safety.

Summary by CodeRabbit

  • New Features
    • Model schema generation now supports Prisma-style field filtering using select, include, and omit options for granular control over schema composition
    • Relation fields can be conditionally included with support for nested options
    • Validation constraints continue to apply appropriately based on selected fields

…chema

Add support for field selection and relation inclusion in Zod schema generation through new options parameter. The `makeModelSchema` method now accepts optional `select`, `include`, and `omit` options to control which fields appear in the generated schema.

Key changes:
- Add overloaded signatures to `makeModelSchema` with type-safe options
- Implement `buildFieldsWithOptions` to handle field filtering and relation inclusion
- Add `GetModelSchemaShapeWithOptions` type to compute resulting shape based on options
- Skip model-level @@Validate on select path to avoid false negatives when referenced fields are not part of the selection
- Add comprehensive type utilities for field selection and relation handling

This enables more flexible schema generation for partial model validation and nested relation schemas while maintaining full type safety.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 20, 2026

📝 Walkthrough

Walkthrough

The makeModelSchema function now supports Prisma-style query options (select, include, omit). When called with options, it constructs filtered Zod schemas where field inclusion depends on the selected option type: select includes only explicitly listed fields, include/omit modifies the full field set. Nested options for relation fields enable recursive schema refinement. New type system and internal helpers enable runtime and compile-time shape construction.

Changes

Cohort / File(s) Summary
Type System Extensions
packages/zod/src/types.ts
Added ModelSchemaOptions union type covering select, include, and omit variants with nested relation options support. Added GetModelSchemaShapeWithOptions conditional type mapping options to Zod shape output, including helpers for scalar/relation field computation.
Factory Implementation
packages/zod/src/factory.ts
Extended makeModelSchema with overloaded signatures accepting optional ModelSchemaOptions. Implemented options-aware field shape construction via buildFieldsWithOptions and makeRelationFieldSchema helpers. Conditionally applies model-level validation (skipped for select, applied for include/omit).
Public API
packages/zod/src/index.ts
Exported new types ModelSchemaOptions and GetModelSchemaShapeWithOptions as type-only re-exports.
Test Coverage
packages/zod/test/factory.test.ts
Added comprehensive tests verifying omit removes scalar keys, include adds relation fields, select restricts to selected fields only, and nested select on relations with strict validation. Tests confirm existing validation constraints (@@validate, field-level) work with options.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop through the schema with options so neat,
Select, include, omit—a Prisma feat!
Relations now filter with recursive delight,
Type-safe narrowing, validation just right!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature addition: Prisma-style select/include/omit options for makeModelSchema, which aligns with the primary changes across all modified files.
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.

Tip

You can validate your CodeRabbit configuration file in your editor.

If your editor has YAML language server, you can enable auto-completion and validation by adding # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json at the top of your CodeRabbit configuration file.

Copy link
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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/zod/src/factory.ts`:
- Around line 98-103: The current logic only skips model-level @@validate when
rawOptions.select is present, but rawOptions.omit can also remove fields
referenced by validations and cause false negatives or impossible schemas;
update the branch that sets withValidation to consider rawOptions.omit as well:
inspect which fields the model-level validations reference (from
modelDef.attributes or the structure holding @@validate expressions) and only
call addCustomValidation(shape, modelDef.attributes) when neither select nor
omit remove any of those referenced fields; otherwise use the plain shape.
Ensure you reference rawOptions.select, rawOptions.omit, withValidation,
addCustomValidation, and modelDef.attributes when locating and changing the
code.

In `@packages/zod/src/types.ts`:
- Around line 272-295: BuildSelectShape currently keeps keys when a select entry
widens from literal true to boolean but SelectEntryToZod only handles literal
true or object, causing widened booleans to resolve to never; update
SelectEntryToZod to treat any boolean (Value extends boolean) the same as true
(i.e., return GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model,
Field>]) before falling back to the object/never branches, and add a unit test
that uses a boolean variable (e.g., const pickEmail: boolean = true;
factory.makeModelSchema('User', { select: { email: pickEmail } })) to assert the
produced type exposes the field shape instead of never; reference types:
SelectEntryToZod, BuildSelectShape, GetModelFieldsShape, FieldInShape.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3fb3fc2e-91f0-43fd-b5ab-b10630375584

📥 Commits

Reviewing files that changed from the base of the PR and between 41ea0c9 and 27e4592.

📒 Files selected for processing (4)
  • packages/zod/src/factory.ts
  • packages/zod/src/index.ts
  • packages/zod/src/types.ts
  • packages/zod/test/factory.test.ts

Comment on lines +98 to +103
// @@validate expressions reference fields by name — when `select` is
// used only a subset of fields is present, so running @@validate would
// silently evaluate missing fields as null and produce false negatives.
// We therefore only apply model-level custom validation on the
// include/omit path where all scalar fields are still present.
const withValidation = rawOptions.select ? shape : addCustomValidation(shape, modelDef.attributes);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

omit can make some schemas impossible to satisfy.

This branch still runs model-level @@validate whenever select is absent, but omit also removes fields from the parsed payload. If an omitted field participates in @@validate, callers lose both options: providing the field fails strict parsing, and omitting it makes the refinement evaluate against missing data. omit: { age: true } together with @@validate(age >= 18) is the minimal example.

💡 Possible fix
-        const withValidation = rawOptions.select ? shape : addCustomValidation(shape, modelDef.attributes);
+        const hasPartialShape =
+            !!rawOptions.select || Object.values(rawOptions.omit ?? {}).some((value) => value === true);
+        const withValidation = hasPartialShape ? shape : addCustomValidation(shape, modelDef.attributes);

If you want to preserve @@validate for safe omit cases later, you can refine this by checking which fields the validation expressions actually reference.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/zod/src/factory.ts` around lines 98 - 103, The current logic only
skips model-level @@validate when rawOptions.select is present, but
rawOptions.omit can also remove fields referenced by validations and cause false
negatives or impossible schemas; update the branch that sets withValidation to
consider rawOptions.omit as well: inspect which fields the model-level
validations reference (from modelDef.attributes or the structure holding
@@validate expressions) and only call addCustomValidation(shape,
modelDef.attributes) when neither select nor omit remove any of those referenced
fields; otherwise use the plain shape. Ensure you reference rawOptions.select,
rawOptions.omit, withValidation, addCustomValidation, and modelDef.attributes
when locating and changing the code.

Comment on lines +272 to +295
type SelectEntryToZod<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
Value,
> = Value extends true
? // `true` — use the default shape for this field (scalar or relation)
GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>]
: Value extends object
? // nested options — must be a relation field
RelationFieldZodWithOptions<Schema, Model, Field, Value>
: never;

/**
* Build the Zod shape for the `select` branch: only the listed fields,
* recursing into relations when given nested options.
*/
type BuildSelectShape<Schema extends SchemaDef, Model extends GetModels<Schema>, S extends Record<string, unknown>> = {
[Field in keyof S & GetModelFields<Schema, Model> as S[Field] extends false ? never : Field]: SelectEntryToZod<
Schema,
Model,
Field,
S[Field]
>;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n packages/zod/src/types.ts | sed -n '260,310p'

Repository: zenstackhq/zenstack

Length of output: 2131


🏁 Script executed:

# Look for test files or usage patterns for BuildSelectShape and SelectEntryToZod
fd -t f '\.test\.(ts|tsx)$|\.spec\.(ts|tsx)$' | xargs grep -l 'BuildSelectShape\|SelectEntryToZod' 2>/dev/null | head -5

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

# Also check if there are any expectTypeOf tests in the zod package
fd -t f '\.test\.(ts|tsx)$|\.spec\.(ts|tsx)$' packages/zod | head -10

Repository: zenstackhq/zenstack

Length of output: 96


🏁 Script executed:

# Let's look at broader context - how is ModelSchemaOptions used?
grep -n "ModelSchemaOptions\|BuildSelectShape" packages/zod/src/types.ts | head -20

Repository: zenstackhq/zenstack

Length of output: 576


🏁 Script executed:

cat -n packages/zod/test/factory.test.ts | head -50

Repository: zenstackhq/zenstack

Length of output: 2001


🏁 Script executed:

# Search for tests that use select with boolean variables
grep -n "select.*:.*boolean\|expectTypeOf" packages/zod/test/factory.test.ts | head -20

Repository: zenstackhq/zenstack

Length of output: 1587


🏁 Script executed:

# Let's also check the ModelSchemaOptions definition and context around line 183-210
cat -n packages/zod/src/types.ts | sed -n '183,210p'

Repository: zenstackhq/zenstack

Length of output: 1439


🏁 Script executed:

# Search for tests with select + boolean variable patterns
grep -n "const.*boolean\|let.*boolean" packages/zod/test/factory.test.ts | head -20

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

# Look for all tests related to select
grep -n "select.*:" packages/zod/test/factory.test.ts | head -30

Repository: zenstackhq/zenstack

Length of output: 1393


🏁 Script executed:

# Let's see more of the test file to understand patterns
cat -n packages/zod/test/factory.test.ts | sed -n '100,200p'

Repository: zenstackhq/zenstack

Length of output: 5437


🏁 Script executed:

# Search for tests that use variables in select/include/omit
grep -B5 -A5 "const.*=.*true\|let.*=.*true" packages/zod/test/factory.test.ts | grep -A10 -B10 "select\|include\|omit"

Repository: zenstackhq/zenstack

Length of output: 7763


🏁 Script executed:

# Let's check if there are type-only test files (for type checking)
fd -t f "\.type-test\.(ts|tsx)$|types\.test\.(ts|tsx)$" packages/zod

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

# Now let's understand the issue better - test if the type system actually resolves boolean to never
# First, let's look at the full definition to understand how BuildSelectShape filters
cat -n packages/zod/src/types.ts | sed -n '268,296p'

Repository: zenstackhq/zenstack

Length of output: 1314


🏁 Script executed:

# Look at how SelectEntryToZod is used and what happens with broader types
cat -n packages/zod/src/types.ts | sed -n '272,283p'

Repository: zenstackhq/zenstack

Length of output: 632


🏁 Script executed:

# Let's create a test to verify the type narrowing behavior
cat > /tmp/test-select-boolean.ts << 'EOF'
import { z } from 'zod';

// Simplified reproduction of the type issue

// Current implementation pattern
type SelectEntryToZod_Current<Value> = 
    Value extends true 
      ? string  // default shape
      : Value extends object
        ? string  // nested options
        : never;

type BuildSelectShape_Current<S extends Record<string, unknown>> = {
    [Field in keyof S as S[Field] extends false ? never : Field]: SelectEntryToZod_Current<S[Field]>;
};

// Test with literal true
type Test1 = BuildSelectShape_Current<{ email: true }>;
// Result: { email: string }

// Test with widened boolean
declare const pickEmail: boolean;
type Test2 = BuildSelectShape_Current<{ email: typeof pickEmail }>;
// Result: { email: never } ← This is the problem!

// Proposed fix pattern
type HasSelectedValue<Value> = true extends Value ? true : Value extends object ? true : false;

type SelectEntryToZod_Fixed<Value> = 
    Value extends object
      ? string  // nested options first
      : true extends Value
        ? string  // default shape
        : never;

type BuildSelectShape_Fixed<S extends Record<string, unknown>> = {
    [Field in keyof S as HasSelectedValue<S[Field]> extends false ? never : Field]: SelectEntryToZod_Fixed<S[Field]>;
};

// Test with widened boolean using fixed version
type Test3 = BuildSelectShape_Fixed<{ email: typeof pickEmail }>;
// Result: { email: string } ← Fixed!

EOF
cat /tmp/test-select-boolean.ts

Repository: zenstackhq/zenstack

Length of output: 1465


🏁 Script executed:

# Now let's check TypeScript to confirm the behavior
cd /tmp && npx -y typescript@latest --noEmit test-select-boolean.ts 2>&1 || echo "Type check completed"

Repository: zenstackhq/zenstack

Length of output: 248


🏁 Script executed:

# Let's also look at how the types are actually exported and used
grep -n "GetModelSchemaShape\|export.*type.*Schema" packages/zod/src/types.ts | head -20

Repository: zenstackhq/zenstack

Length of output: 810


🏁 Script executed:

# Let's verify if there are any current usages that pass variables (not literals) to select
grep -r "select.*:.*[a-zA-Z]" packages/zod/test/factory.test.ts | grep -v "true\|false\|{" | head -10

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

# Let's check the full context of GetModelSchemaShapeWithOptions to understand the complete flow
cat -n packages/zod/src/types.ts | sed -n '338,360p'

Repository: zenstackhq/zenstack

Length of output: 659


🏁 Script executed:

# Search for any edge case tests or comments about select/include/omit behavior with variables
grep -n "variable\|const.*:.*boolean\|let.*:.*boolean\|widened" packages/zod/src/types.ts packages/zod/test/factory.test.ts

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

# Let's verify one more thing - does the actual factory function accept the options at runtime?
grep -n "makeModelSchema" packages/zod/src/index.ts | head -10

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

# Check if there are integration tests or examples
fd -t f "\.test\.(ts|tsx)$" packages/zod | xargs wc -l | tail -1

Repository: zenstackhq/zenstack

Length of output: 101


🏁 Script executed:

# Let's verify the behavior by creating a minimal TS test case and checking diagnostics
cat > /tmp/verify_type_issue.ts << 'EOF'
// Minimal reproduction showing the type narrowing issue with widened booleans

type SelectEntryToZod<Value> = 
    Value extends true 
      ? "DEFAULT_SHAPE"
      : Value extends object
        ? "NESTED_OPTIONS"
        : "NEVER";

type BuildSelectShape<S extends Record<string, unknown>> = {
    [Field in keyof S as S[Field] extends false ? never : Field]: SelectEntryToZod<S[Field]>;
};

// Case 1: Literal true (works correctly)
type Test1 = BuildSelectShape<{ email: true }>;
// Expected: { email: "DEFAULT_SHAPE" }
// Actual: { email: "DEFAULT_SHAPE" } ✓

// Case 2: Variable with widened boolean (problem case)
declare const boolVar: boolean;
type Test2 = BuildSelectShape<{ email: typeof boolVar }>;
// Expected: { email: "DEFAULT_SHAPE" }
// Actual: { email: "NEVER" } ✗

// Case 3: Literal false (correctly excluded)
type Test3 = BuildSelectShape<{ email: false }>;
// Expected: {}
// Actual: {} ✓

// Verification that the problem is real
const test2Result: Test2 = { email: "NEVER" };  // This assignment works but is wrong type inference
EOF
cat /tmp/verify_type_issue.ts

Repository: zenstackhq/zenstack

Length of output: 1117


🏁 Script executed:

# Let's check if there are actual production usages passing variables to select
grep -r "select.*:" packages/ --include="*.ts" --include="*.tsx" | grep -v "test\|spec" | grep -v "select.*true\|select.*false\|select.*{" | head -10

Repository: zenstackhq/zenstack

Length of output: 990


🏁 Script executed:

# Check the full file structure to understand if ModelSchemaOptions is exposed in public API
grep -n "export.*ModelSchemaOptions\|export.*GetModelSchemaShapeWithOptions" packages/zod/src/index.ts packages/zod/src/types.ts

Repository: zenstackhq/zenstack

Length of output: 369


🏁 Script executed:

# Let's verify the proposed fix would actually work by testing the HasSelectedValue pattern
cat > /tmp/test_proposed_fix.ts << 'EOF'
// Test the proposed fix pattern

type HasSelectedValue<Value> = true extends Value ? true : Value extends object ? true : false;

type SelectEntryToZod_Fixed<Value> =
    Value extends object
      ? "NESTED_OPTIONS"
      : true extends Value
        ? "DEFAULT_SHAPE"
        : never;

type BuildSelectShape_Fixed<S extends Record<string, unknown>> = {
    [Field in keyof S as HasSelectedValue<S[Field]> extends false ? never : Field]: SelectEntryToZod_Fixed<S[Field]>;
};

// Case 1: Literal true (should work)
type Test1_Fixed = BuildSelectShape_Fixed<{ email: true }>;
// Expected: { email: "DEFAULT_SHAPE" }

// Case 2: Widened boolean (should be fixed)
declare const boolVar: boolean;
type Test2_Fixed = BuildSelectShape_Fixed<{ email: typeof boolVar }>;
// Expected: { email: "DEFAULT_SHAPE" }

// Case 3: Literal false (should be excluded)
type Test3_Fixed = BuildSelectShape_Fixed<{ email: false }>;
// Expected: {}

// Case 4: Nested object (should work)
type Test4_Fixed = BuildSelectShape_Fixed<{ posts: { select: { title: true } } }>;
// Expected: { posts: "NESTED_OPTIONS" }

// Case 5: Mixed
type Test5_Fixed = BuildSelectShape_Fixed<{ email: true; posts: { select: {} }; username: false; active: typeof boolVar }>;
// Expected: { email: "DEFAULT_SHAPE"; posts: "NESTED_OPTIONS"; active: "DEFAULT_SHAPE" }

// Verification
const t1: Test1_Fixed = { email: "DEFAULT_SHAPE" };
const t2: Test2_Fixed = { email: "DEFAULT_SHAPE" };
const t3: Test3_Fixed = {};
const t4: Test4_Fixed = { posts: "NESTED_OPTIONS" };
const t5: Test5_Fixed = { email: "DEFAULT_SHAPE", posts: "NESTED_OPTIONS", active: "DEFAULT_SHAPE" };
EOF
cat /tmp/test_proposed_fix.ts

Repository: zenstackhq/zenstack

Length of output: 1690


🏁 Script executed:

# Check if there's any runtime handling that would mask this type issue
grep -n "select.*=" packages/zod/src/index.ts | head -15

Repository: zenstackhq/zenstack

Length of output: 45


🏁 Script executed:

# Let's verify the actual implementation/signature of makeModelSchema
cat -n packages/zod/src/index.ts | head -50

Repository: zenstackhq/zenstack

Length of output: 252


Widened select booleans turn kept fields into never.

BuildSelectShape removes a key only when literally false, but SelectEntryToZod only resolves a schema when literally true or an object. If a field's value widens from literal true to boolean (e.g., stored in a variable), the key remains in the shape but narrows to never. Runtime schema generation works; the exported type surface breaks.

Example:

const pickEmail: boolean = true;
factory.makeModelSchema('User', { select: { email: pickEmail } })
// Type: { email: never } (should be { email: string })
💡 Suggested fix
+type HasSelectedValue<Value> = true extends Value ? true : Value extends object ? true : false;
+
 type SelectEntryToZod<
     Schema extends SchemaDef,
     Model extends GetModels<Schema>,
     Field extends GetModelFields<Schema, Model>,
     Value,
-> = Value extends true
-    ? // `true` — use the default shape for this field (scalar or relation)
-      GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>]
-    : Value extends object
-      ? // nested options — must be a relation field
-        RelationFieldZodWithOptions<Schema, Model, Field, Value>
-      : never;
+> = Value extends object
+    ? RelationFieldZodWithOptions<Schema, Model, Field, Value>
+    : true extends Value
+      ? // `true` or widened boolean — use the default shape
+        GetModelFieldsShape<Schema, Model>[FieldInShape<Schema, Model, Field>]
+      : never;
 
 /**
  * Build the Zod shape for the `select` branch: only the listed fields,
  * recursing into relations when given nested options.
  */
 type BuildSelectShape<Schema extends SchemaDef, Model extends GetModels<Schema>, S extends Record<string, unknown>> = {
-    [Field in keyof S & GetModelFields<Schema, Model> as S[Field] extends false ? never : Field]: SelectEntryToZod<
+    [Field in keyof S & GetModelFields<Schema, Model> as HasSelectedValue<S[Field]> extends false ? never : Field]: SelectEntryToZod<
         Schema,
         Model,
         Field,
         S[Field]
     >;
 };

Add a test case with a boolean variable in select to catch this regression.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/zod/src/types.ts` around lines 272 - 295, BuildSelectShape currently
keeps keys when a select entry widens from literal true to boolean but
SelectEntryToZod only handles literal true or object, causing widened booleans
to resolve to never; update SelectEntryToZod to treat any boolean (Value extends
boolean) the same as true (i.e., return GetModelFieldsShape<Schema,
Model>[FieldInShape<Schema, Model, Field>]) before falling back to the
object/never branches, and add a unit test that uses a boolean variable (e.g.,
const pickEmail: boolean = true; factory.makeModelSchema('User', { select: {
email: pickEmail } })) to assert the produced type exposes the field shape
instead of never; reference types: SelectEntryToZod, BuildSelectShape,
GetModelFieldsShape, FieldInShape.

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.

1 participant