Overview
A schema in Ship is two things in one file: the Drizzle table that defines the database shape and the Zod schemas that validate input and output at the edge. Both colocate in the resource folder:DbService (pnpm --filter api codegen).
The Drizzle table
Every table spreadsbaseColumns from resources/base.schema.ts, which gives it a uuid id, timestamps, and a soft-delete column:
apps/api/src/resources/base.schema.ts
apps/api/src/resources/users/users.schema.ts
deletedAt is the soft-delete column. Reads filter where: { deletedAt: null }; deletes set the timestamp instead of removing the row. Every baseColumns table works this way.The Zod schemas
Derive the validation schema from the table withcreateSelectSchema, then refine the fields you want stricter rules on. Export a publicSchema — the shape endpoints return to clients:
apps/api/src/resources/users/users.schema.ts
Ship is on Zod 4. Use
z.email(), z.url(), z.uuid() — not Zod 3’s z.string().email().Enums come from app-constants
Never inline a string enum. Shared constants live in the app-constants package, so the API, the web app, and your schemas agree on the same values:
Schemas are the endpoint contract
The same Zod schemas drive every endpoint..input() and .output() validate at runtime and define the types the typed client sees — there is no second source of truth to keep in sync:
apps/api/src/resources/users/endpoints/list.ts
paginationSchema and listResultSchema(itemSchema) come from resources/base.schema.ts and standardise paged responses ({ results, count, pagesCount }).
Reusing a schema for input
Build write inputs bypick-ing, extend-ing, or partial-ing the resource schema. The avatar update endpoint takes only fullName plus an uploaded file:
apps/api/src/resources/users/endpoints/current.patch.ts
Validating elsewhere
The same Zod schema validates on the client too — feed it touseApiForm (provided by the Auth plugin) or a manual zodResolver:
safeParse:
