Field Policies

Field policies control where a field appears in the API surface. They are applied at the model level and affect schema derivation, CRUD inputs, and response shaping automatically.

Policy quick reference

Policy DB column Create input Update input API output
(none)
readOnly()
writeOnly()
serverOnly()

readOnly()

Use readOnly() for fields that are set once at creation time and never changed by callers. Typical patterns are auto-generated UUIDs and creation timestamps.

const fields = {
  id:        t.uuid().primary().readOnly(),
  createdAt: t.timestamp().readOnly().default(() => new Date()),
}
  • The field is present in every response.
  • The field is excluded from inputSchema('create') and inputSchema('update').
  • For t.uuid().primary().readOnly(), Nanoka automatically sets $defaultFn(() => crypto.randomUUID()) so the value is generated on insert.

writeOnly()

Use writeOnly() for fields that must be accepted as input but must never appear in responses. A classic example is a field that stores a token or a value derived from user input.

const fields = {
  verificationToken: t.string().writeOnly(),
}
  • The field is accepted in create and update inputs.
  • The field is stripped from every response by toResponse() and toResponseMany().
  • Note: storing a plain password as writeOnly() is not a secure pattern. Plain passwords must be hashed before storage. Use serverOnly() for the hash column and accept the plain password through a custom extend() on inputSchema('create').

serverOnly()

Use serverOnly() for fields that only server-side code should touch. The field exists in the database but is invisible to external callers from both directions.

const fields = {
  passwordHash: t.string().serverOnly(),
}
  • The field is not accepted in create or update inputs.
  • The field is not present in any response.
  • serverOnly fields are completely excluded from CreateInput<Fields>. Passing them to User.create() is a TypeScript error. To write a serverOnly field to the database, use the app.db escape hatch directly.
// ❌ serverOnly fields cannot be passed to User.create()
// await User.create({ ...body, passwordHash })  // TypeScript error

// ✅ Use app.db directly (escape hatch)
const hash = await bcrypt.hash(body.password, 10)
await app.db.insert(User.table).values({
  id: crypto.randomUUID(),
  email: body.email,
  name: body.name,
  passwordHash: hash,
})

Warning: do not re-inject serverOnly fields via extend()

When extending inputSchema('create') with a custom Zod shape, do not add the serverOnly field back to the schema. Doing so would expose the field as an accepted API input, defeating the purpose of serverOnly().

// BAD — exposes passwordHash as an accepted API input
const CreateUserBody = User.inputSchema('create').extend({
  passwordHash: z.string(),  // never do this
})

// GOOD — accept plaintext password, hash server-side, write via app.db
const CreateUserBody = User.inputSchema('create').extend({
  password: z.string().min(8),
})

app.post('/users', zValidator('json', CreateUserBody), async (c) => {
  const { password, ...body } = c.req.valid('json')
  const passwordHash = await bcrypt.hash(password, 10)
  await app.db.insert(User.table).values({ ...body, passwordHash })
  const user = await User.findOne({ email: body.email })
  return c.json(User.toResponse(user!), 201)
})

pick / omit combined with field accessor

When you need to further narrow a schema beyond what policies provide, use the field accessor to get compile-time typo detection:

// Typo here is a type error, not a silent runtime miss
const PatchSchema = User.schema({ pick: (f) => [f.name, f.email] })

// Same for omit
const ResponseSchema = User.outputSchema({ omit: (f) => [f.createdAt] })

The field accessor f maps each field name to itself as a string literal, so f.nme or f.emails would fail to type-check immediately.