Skip to main content

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:
apps/api/src/resources/users/
  users.schema.ts        # pgTable + Zod schemas
  endpoints/             # .input()/.output() reference the Zod schemas
There is no separate schemas package and no generate step that copies schemas around. The schema file is the source of truth; codegen reads it to build the typed DbService (pnpm --filter api codegen).

The Drizzle table

Every table spreads baseColumns from resources/base.schema.ts, which gives it a uuid id, timestamps, and a soft-delete column:
apps/api/src/resources/base.schema.ts
import { timestamp, uuid } from 'drizzle-orm/pg-core';

export const baseColumns = {
  id: uuid('id').defaultRandom().primaryKey(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).$onUpdate(() => new Date()),
  deletedAt: timestamp('deleted_at', { withTimezone: true }),
};
A resource table extends it with its own columns:
apps/api/src/resources/users/users.schema.ts
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core';

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

export const users = pgTable('users', {
  ...baseColumns,

  fullName: text('full_name').notNull(),
  email: text('email').notNull().unique(),

  isAdmin: boolean('is_admin').default(false).notNull(),
  isEmailVerified: boolean('is_email_verified').default(false).notNull(),

  avatarUrl: text('avatar_url'),

  lastRequestAt: timestamp('last_request_at', { withTimezone: true }),
});
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 with createSelectSchema, 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
import { createSelectSchema } from 'drizzle-orm/zod';
import { z } from 'zod';

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

const statusSchema = z.enum(USER_STATUSES);

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
import db from '@/db';
import endpoint from '@/endpoint';
import isAdmin from '@/middlewares/is-admin';
import { listResultSchema, paginationSchema } from '@/resources/base.schema';
import { publicSchema } from '../users.schema';

export default endpoint
  .use(isAdmin)
  .input(paginationSchema)
  .output(listResultSchema(publicSchema))
  .handler(async ({ input }) => {
    return db.users.findPage({ where: { deletedAt: null }, ...input });
  });
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 by pick-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
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 }) => {
    /* ... */
  });

Validating elsewhere

The same Zod schema validates on the client too — feed it to useApiForm (provided by the Auth plugin) or a manual zodResolver:
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

const schema = z.object({
  fullName: z.string().min(1, 'Full name is required').max(128),
  email: z.email('Email format is incorrect.'),
});

type SignUpParams = z.infer<typeof schema>;

const methods = useForm<SignUpParams>({ resolver: zodResolver(schema) });
Or parse imperatively with safeParse:
const parsed = schema.safeParse(data);

if (!parsed.success) {
  throw new Error('Invalid data');
}
For more on Zod, see the Zod documentation.