feat(zod): add Prisma-style select/include/omit options to makeModelSchema#2503
feat(zod): add Prisma-style select/include/omit options to makeModelSchema#2503marcsigmund wants to merge 1 commit intozenstackhq:devfrom
Conversation
…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.
📝 WalkthroughWalkthroughThe Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment 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 |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
packages/zod/src/factory.tspackages/zod/src/index.tspackages/zod/src/types.tspackages/zod/test/factory.test.ts
| // @@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); |
There was a problem hiding this comment.
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.
| 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] | ||
| >; |
There was a problem hiding this comment.
🧩 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 -5Repository: 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 -10Repository: 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 -20Repository: zenstackhq/zenstack
Length of output: 576
🏁 Script executed:
cat -n packages/zod/test/factory.test.ts | head -50Repository: 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 -20Repository: 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 -20Repository: 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 -30Repository: 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/zodRepository: 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.tsRepository: 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 -20Repository: 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 -10Repository: 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.tsRepository: 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 -10Repository: 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 -1Repository: 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.tsRepository: 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 -10Repository: 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.tsRepository: 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.tsRepository: 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 -15Repository: 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 -50Repository: 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.
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
makeModelSchemamethod now accepts optionalselect,include, andomitoptions to control which fields appear in the generated schema.Key changes:
makeModelSchemawith type-safe optionsbuildFieldsWithOptionsto handle field filtering and relation inclusionGetModelSchemaShapeWithOptionstype to compute resulting shape based on optionsThis enables more flexible schema generation for partial model validation and nested relation schemas while maintaining full type safety.
Summary by CodeRabbit
select,include, andomitoptions for granular control over schema composition