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:
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { createSelectSchema } from 'drizzle-orm/zod';
import { z } from 'zod';

import { baseColumns } from '../base.schema';

export const users = pgTable('users', {
  ...baseColumns,
  id: text('id').primaryKey(),
  fullName: text('full_name').notNull(),
  email: text('email').notNull().unique(),
  isAdmin: boolean('is_admin').default(false).notNull(),
  avatarUrl: text('avatar_url'),
});

export const emailSchema = z
  .email()
  .min(1, 'Email is required')
  .toLowerCase()
  .trim()
  .max(255, 'Email must be less than 255 characters.');

const usersSchema = createSelectSchema(users, {
  fullName: (schema) => schema.min(1, 'Full name is required').max(128),
  email: () => emailSchema,
});

export default usersSchema;

export const publicSchema = usersSchema;
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:
import { z } from 'zod';

export const paginationSchema = z.object({
  page: z.coerce.number().default(1),
  perPage: z.coerce.number().default(10),
  searchValue: z.string().optional(),
});

export const listResultSchema = <T extends z.ZodType>(itemSchema: T) =>
  z.object({
    results: z.array(itemSchema),
    pagesCount: z.number(),
    count: z.number(),
  });

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:
import { z } from 'zod';

import endpoint from '@/endpoint';
import isAuthorized from '@/middlewares/is-authorized';
import usersSchema, { publicSchema } from '@/resources/users/users.schema';

export default endpoint
  .use(isAuthorized)
  .input(
    usersSchema
      .pick({ fullName: true })
      .extend({ avatar: z.instanceof(File).optional() })
      .partial(),
  )
  .output(publicSchema)
  .handler(async ({ input, context }) => {
    // input.fullName is typed string | undefined; already validated
  });
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():
GateImportGuarantees
isAuthorized@/middlewares/is-authorizeda signed-in context.user
isAdmin@/middlewares/is-adminthe user is an admin
canAccess(key, load)@/middlewares/can-accessloads an entity into context[key] or throws NOT_FOUND
canEdit(key, service)@/middlewares/can-editthe 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:
import db from '@/db';
import canEdit from '@/middlewares/can-edit';

// Loads the current user's note into context.note, or throws NOT_FOUND.
export default canEdit('note', db.notes, { message: 'Note not found' });
Apply it after .input() so it can read the id from the parsed input:
export default endpoint
  .use(isAuthorized)
  .input(z.object({ id: z.string() }))
  .use(canEditNote)
  .output(z.void())
  .handler(async ({ input }) => {
    await db.notes.deleteOne({ id: input.id });
  });
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:
import canAccess from '@/middlewares/can-access';

const memberCanAccessTeam = canAccess('team', async ({ input, context }) =>
  db.teams.findFirst({
    where: { id: input.teamId, ownerId: context.user.id, deletedAt: null },
  }),
);

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:
import { ORPCError } from '@orpc/server';

const existing = await db.users.findFirst({ where: { email, deletedAt: null } });
if (existing) {
  throw new ORPCError('CONFLICT', { message: 'User with this email is already registered' });
}
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