Skip to main content

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:
  1. Runtime — input is parsed and validated before your handler runs; output is validated before it’s sent.
  2. Types — the same schemas type the oRPC client, so the web app sees exactly what the endpoint accepts and returns.
There’s no separate validation step and no parsed-data bag to fish through — 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 in resources/<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. The current.patch.ts endpoint picks one field off the table schema, adds an upload field, and makes everything optional:
The matching .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(): canEdit is built on canAccess. A per-resource ownership gate is a one-liner in <resource>/middlewares/can-edit-*.ts:
Apply it after .input() so it can read the id from the parsed input:
Because mismatches return 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 for canAccess directly. It takes a loader and stores whatever it returns:

Uniqueness

Uniqueness is best enforced at the database level — note email: 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:
To fail a request, throw an ORPCError with a standard code (NOT_FOUND, UNAUTHORIZED, FORBIDDEN, CONFLICT, BAD_REQUEST). Don’t write to a response object — the handler’s return value is the only success path.

Next steps