Overview
In Ship, validation is the contract. An endpoint’s.input() and .output() Zod schemas are the single source of truth for two things at once:
- Runtime — input is parsed and validated before your handler runs; output is validated before it’s sent.
- Types — the same schemas type the oRPC client, so the web app sees exactly what the endpoint accepts and returns.
input arrives already parsed and typed.
Ship is on Zod 4. Use the top-level formats —
z.email(), z.url(), z.uuid() — instead of Zod 3’s z.string().email(). Match the existing schemas in resources/<name>/<name>.schema.ts.Schemas live with the resource
Each resource colocates its Drizzle table and its Zod schemas inresources/<name>/<name>.schema.ts. From users.schema.ts:
createSelectSchema derives a Zod schema from the table, so the row shape and the validation shape never drift. Reuse it in endpoints with .pick(), .extend(), .partial() rather than re-declaring fields.
Shared building blocks
resources/base.schema.ts provides the pagination input and the list-result wrapper used across resources:
Validating input and output
Compose schemas right on the builder. Thecurrent.patch.ts endpoint picks one field off the table schema, adds an upload field, and makes everything optional:
.output(publicSchema) means the client knows the exact response type, and oRPC rejects a handler that returns the wrong shape.
Ownership and existence are gates, not handler code
Don’t re-check “does this exist?” or “may this user touch it?” inside the handler. Those are authorization concerns, and Ship expresses them as middleware you stack with.use():
| Gate | Import | Guarantees |
|---|---|---|
isAuthorized | @/middlewares/is-authorized | a signed-in context.user |
isAdmin | @/middlewares/is-admin | the user is an admin |
canAccess(key, load) | @/middlewares/can-access | loads an entity into context[key] or throws NOT_FOUND |
canEdit(key, service) | @/middlewares/can-edit | the current user owns the entity, loaded into context[key] (or NOT_FOUND — no existence leak) |
canEdit is built on canAccess. A per-resource ownership gate is a one-liner in <resource>/middlewares/can-edit-*.ts:
.input() so it can read the id from the parsed input:
NOT_FOUND rather than FORBIDDEN, the gate never leaks whether a resource exists to a user who isn’t allowed to see it.
Custom existence checks
When “exists” means something more than load-by-id — a relationship, a link table, a relation-loaded query — reach forcanAccess directly. It takes a loader and stores whatever it returns:
Uniqueness
Uniqueness is best enforced at the database level — noteemail: text('email').notNull().unique() on the users table. When you also want a friendly, field-targeted error before the insert, check it in the handler and throw:
Next steps
- API action — authoring the endpoint the validator wraps.
- API conventions — where schemas, gates and services belong.
