Reviewing prisma-guard Shapes: Ownership, Projection and Write Contracts
Learn to review prisma-guard shapes as API contracts by asking who owns each value, which data can leave a response, and which writes a generated endpoint can run.
This article is published in English.
Sources: Define Every Prisma Endpoint’s Inputs and Outputs in One Place
Prisma's argument language is expressive, and inside trusted server code that is exactly what you want. Once the same argument structure is accepted over HTTP, though, every capability of that language turns into a product decision: what a caller may write, which filters it may pick, what the server must pin, which relations may be returned, and how many child rows one response may expand into. prisma-guard shapes let you record those answers directly beside the Prisma operation. This guide shows how to review a shape as a compact contract for control, filtering, projection and mutation, using three questions you can bring to any pull request that adds a generated endpoint.
Why a shape is more than validation
A shape does validate input, but its bigger benefit is that it puts boundary decisions in one location. A generated operation can lean on the shape instead of every handler rebuilding input schemas and field allowlists by hand. That removes repetitive code and also removes the risk that two handlers quietly drift apart in what they allow.
The price of that convenience is precision. A shape is a trustworthy contract only if it matches what the library really enforces, and that includes being honest about where top-level tenant scope stops applying. The behavior below reflects the implementation as its maintainers test it at the time of writing rather than an established community convention, so confirm details against the documentation for your version. If a guarded request is already failing and you want to know why, start with our phase-based diagnostic model for prisma-guard failures.
Question one: who owns each value?
Each leaf of a shape hands a value either to the client or to the server. The vocabulary is small; the example shows all four forms side by side.
const values = {
clientChooses: true,
serverChooses: 'published',
serverChoosesTrue: force(true),
clientChoosesWithinRule: (base) => base.min(1).max(120),
}
Read the leaves carefully:
- Bare
trueis not the Boolean value. It is a sentinel meaning "the client decides". - A literal such as
'published'belongs to the server. force(value)also belongs to the server. You need it when the value you want to pin is literallytrue, since a baretruewould be read as the client sentinel.- A refinement function leaves the decision with the client but swaps the field's generated Zod chain for the rule you write inline.
Placement changes what a force means
The words are simple; their location is what surprises people. In a where shape, a forced scalar goes inside an operator object. The event filter below pins publication status while still letting the client search titles:
const eventShape = {
where: {
isPublished: { equals: force(true) },
title: { contains: true },
},
}
When an entire scalar predicate at the root of where is forced, the library treats client input on that field leniently. Whether the caller repeats the pinned value, sends a different one, or adds some other operator, the server's predicate takes precedence and the request is not rejected merely for mentioning the field. That is not the same as a default. A default steps aside when the client supplies something; a force never steps aside.
In every other position, forces are strict. Think of a forced modifier sitting beside an operator the client controls, a pinned field within a relation filter, a pinned filter on a nested include, or a pinned value in mutation data. In all of these cases the key disappears from the schema the client is validated against, so sending it fails validation even if the value matches exactly.
This matters for client code. A client may have to drop a server-owned write field completely. "Fixing" it to the server's value still fails, because the check is about who owns the key, not whether the values are equal.
Defaults, catches and refinements move ownership too
Zod defaults and catches deserve the same scrutiny. On a field the client controls, a default keeps its normal behavior when the value is omitted, and a catch may swap bad input for a fallback rather than failing the request. Create operations add a twist: a default or catch directive produced by the generator can even fill in a required field that the shape never mentions, and the value it produces is then injected as though the server had pinned it. Where you put a default therefore decides who owns the value, so rely on substitution only when the API contract calls for it.
Refinements hide a related trap. They do not extend the generated chain at their node; they replace it. Your function must therefore restate every constraint the endpoint depends on, not only the extra minimum or maximum you meant to add. This is especially important when the Prisma type is looser than the product rule, for example a string column that must never be empty.
Forced updates and forced disjunctions
Pinned update data is written on every call that matches. Suppose an update shape forces status: 'active': any update through it, even one that only edits a title, also resets the status. If an operation must leave a field alone, keep that field out of the shape.
Boolean structure adds one more rule. A forced condition placed under OR gets hoisted into a required condition at the root. As a result, a shape has no way to express a real server-owned "either" rule, such as records that belong to this account or that were shared with this group. Rules like that need a query written on the server, a single scoping column that captures the policy, or enforcement in the database itself.
The first review question is therefore sharper than "is this validated?" Ask who decides each value, where every force is placed, and what argument tree actually arrives at Prisma.
Question two: what can a response expose?
Filtering and projection answer different questions. A tenant filter decides which records qualify. A projection decides which of their fields and relations are sent back. You need both, and checking one tells you nothing about the other.
For guarded reads, a select or include declared on the shape is simultaneously an allowlist and the fallback. Clients may request a narrower tree but nothing beyond it, and when they send no projection, the guarded delegate uses the declared one.
The ticket shape below lets a client fetch a ticket by id, returns a fixed set of root fields, and exposes a nested list of customer notes with its own filter, fields and page size.
const ticketShape = {
where: {
id: { equals: true },
},
select: {
id: true,
subject: true,
description: true,
customerNotes: {
where: {
accountId: { equals: force(accountId) },
},
select: {
id: true,
body: true,
},
take: { max: 20, default: 10 },
},
},
}
That one tree does four separate jobs:
- Root fields that are not listed stay unavailable.
- Only the listed note fields can be returned.
- Notes are always restricted to the caller's account.
- A single response holds at most 20 notes, and 10 when the client does not say.
Nested relations sit outside automatic scope
The note filter is written out on purpose. Automatic tenant scoping applies to the supported operations at the root of the query only; it does not walk into relations fetched through select or include and add conditions there. Forget the nested where, and a correctly scoped ticket can still return notes that belong to another account.
To-many relations accept a nested where, their own projection and a row bound. Sensitive to-one relations cannot take a nested Prisma where, so you need another strategy: leave the relation out, expose only harmless scalar fields from it, load it through its own guarded root-level query, or protect it with a database policy.
Choosing a projection style
Choose the projection form by how it behaves as the schema grows:
- With
select, a column added to the model later stays hidden until someone reviews it and lists it, which makes this the safest choice for public endpoints. - With
include, you gain relations but the root model still returns all of its scalar columns, so sensitive root data remains visible. - With
omit, you maintain a denylist, and whether it is available depends on your generator and Prisma version.
These are alternatives, not layers. Never mix select, include and omit at one level of the tree.
Counts, nested defaults and mutation responses
A relation count is a separate projection node rather than a summary linked to the relation you loaded. If a returned to-many relation is filtered by account, its _count must carry the same filter whenever clients assume both numbers describe the same rows. Test the generated relation condition and the count condition independently.
Nested projection defaults are derived from the declared structure. If a client asks for a declared relation with Boolean shorthand, the relation can still keep its pinned filter and page bound. Permissions granted to the client, however, do not fill in missing choices: allowing sorting by a field does not supply a direction the client left out. Cover three cases in tests, an omitted root projection, an explicit nested projection and relation shorthand, rather than assuming one proves the rest.
Mutations are intentionally asymmetric. A mutation shape can check a projection that the client requests, but unless enforceProjection is on, a client that asks for nothing receives no shaped projection and Prisma's usual behavior applies. Turn enforcement on whenever mutation responses must be limited to declared fields.
Finally, make sure each test runs the phase it claims to test. Calling guard.query(...).parse() is a good way to check read filters and bounds, but read projection defaults only show up once the guarded delegate runs, and mutation projection likewise requires the guarded mutation path.
So the second question is not "is there a select?" Ask which root fields, related fields, related rows and counts every caller variant could receive.
Question three: what writes can run?
Generated write endpoints take arguments in Prisma's own format. The data shape determines which values the client provides, which the server supplies, and whether any nested operations are possible.
Create shapes must account for every required field
A create shape is rejected when some required field has no source. Acceptable sources include:
- the client, when the field is marked as client-controlled
- the server, through a literal or
force - a default defined in the Prisma schema
- a supported Zod default or catch
- scope injection of the tenant foreign key
- a permitted relation write that fills it
The subscription example lets the client choose an external key and plan, limits the seat count with an integer rule, pins the starting status and version on the server, and returns a narrow projection.
const createSubscriptionShape = {
data: {
externalKey: (base) => base.min(1).max(100),
planId: true,
status: 'trialing',
seatCount: (base) => base.int().min(1).max(500),
version: 0,
},
select: {
id: true,
externalKey: true,
status: true,
seatCount: true,
},
}
Notice what is absent: the tenant foreign key. It should never be something the client sets on create. The scope root comes from trusted request context, and the extension adds the matching foreign key itself for the root-level writes it supports.
Bulk operations and batch creates
Bulk mutations have to clear two checks. First, updateMany, its returning variants and deleteMany need the shape to declare a where vocabulary. Second, the incoming request has to produce a real condition. A missing body, an empty operator object or a logical branch that matches everything does not qualify, which guards against an accidental table-wide change.
Guarded batch creates insist on an array and do not silently wrap a lone object. Whether returning batch operations and options like duplicate skipping work still depends on your database provider.
Upsert is three contracts
An upsert shape is really three shapes: a unique selector, a payload for creation and a payload for updates.
const upsertSubscriptionShape = {
where: {
externalKey: true,
},
create: {
externalKey: true,
planId: true,
status: 'trialing',
seatCount: true,
version: 0,
},
update: {
planId: true,
seatCount: true,
},
}
Every required field must be covered in the create payload, while the update payload can leave anything out. A force under create only sets the value when a row is inserted, whereas the same force under update overwrites it whenever an existing row matches. In the example, status and version are pinned on create and deliberately left out of update, so existing subscriptions keep what they have.
Unique mutations take Prisma's plain unique-selector form. Operator objects are for ordinary filtering and have no place in a root-level unique where shape.
Relation writes and return contracts
Relation writes call for a separate ownership decision. A shape may allow connect, limit the selector keys a client can use and reject any nested operation it does not list. None of that extends tenant scope into the selector, though. When it matters which record gets connected, your application code or the database must check that the caller is allowed to use it.
Return contracts also vary by method. The count-only batch methods, createMany, updateMany and deleteMany, reply with a count payload and reject projections. Their returning counterparts can take projections if both Prisma and your provider allow it. If a generator setting switches a route to a returning method, review the new response as an API change rather than dismissing it as an implementation detail.
The third question is not "is this method switched on?" Ask what the method makes available: data fields, selector forms, bulk conditions, relation operations and response fields.
Layering the enforcement
Shapes work best when their limits are documented alongside the other mechanisms. Each layer has its own job:
- The authenticated request provides context you can trust.
- Scope mappings add tenant conditions to supported root-level operations.
- Caller variants pick which declared shape applies to a given audience.
- Hooks run bounded application checks that are allowed to reject.
- Hand-written handlers own the queries that shapes cannot describe.
- Database policy protects invariants that must survive any access path.
None of these should pretend to do another's job. If you are deciding which routes to generate and which to write by hand, our article on balancing Prisma CRUD generation with deliberate route control covers that split.
A review and test checklist
When a pull request adds a generated endpoint, review it with three questions:
- Who decides each value the endpoint accepts?
- Which fields and relations can a response contain?
- Which mutations and selector forms can a caller run?
Then aim each test at the specific claim it should prove. Parse read arguments to confirm filter behavior. Run guarded delegates to confirm projections and writes. Examine the extension path to confirm scope injection. Reach for multi-tenant database fixtures when the claim is about which rows are returned, and for HTTP tests when it is about transport or the generated routes.
Give negative tests equal weight. For each audience, attempt to read a root field that is not listed, read a sensitive nested field, exceed a relation bound, and run a write operation the shape forbids. Positive tests prove the intended request succeeds; negative tests prove the edges stay shut after the model or the generator changes.
Wrapping up
A shape earns the name "contract" only when it is clear both about what it allows and about what it cannot enforce. Reviewing ownership, projection and writes separately surfaces the typical gaps early: lenient root-level forces, unscoped nested relations, counts that ignore relation filters, and connects that bypass tenant scope. Once those gaps are assigned to hooks, handlers or database policy, code generation can eliminate repetitive boundary code without turning Prisma's query language into a public API nobody meant to publish. For full syntax, exact error messages, version-specific behavior and reproducible labs, see the prisma-generator-express field guide.