Model source compatibility
Use $satisfies<Source>() to catch drift without importing source code into the client.
A model is an explicit public wire contract. When it represents part of an
upstream row or domain object, $satisfies<Source>() proves that the selected
fields still agree with that source:
interface UserRow {
id: string;
name: string;
avatarUrl: string | null;
passwordHash: string;
}
export const User = defineModel("user", {
key: "id",
shape: {
id: wire.string,
name: wire.string,
avatarUrl: wire.union([wire.string, wire.null] as const),
},
}).$satisfies<UserRow>();
The assertion enforces one precise relationship:
- Every model field must exist in the source.
- Its TypeScript type and nullability must match exactly.
- The source may contain additional fields. They do not join the model.
- The method returns the same model and performs no runtime reflection.
passwordHash is allowed as a source field but is absent from the model, so
it cannot appear in a model view or RPC response. If avatarUrl becomes
non-nullable—or the model forgets that it is nullable—the assertion stops
type-checking.
Type-only sources
Use import type when the source lives in a server module:
import type { notes } from "../server/schema.js";
export const Note = defineModel("note", {
key: "id",
shape: {
id: wire.string,
title: wire.string,
updatedAt: wire.date,
},
}).$satisfies<typeof notes.$inferSelect>();
TypeScript erases that import. The client receives the defineModel codec,
not the database table, its builders, driver, or private column names. This
works with any source type: a query row, Prisma payload, Kysely result,
generated API type, or ordinary interface.
What it does not prove
$satisfies<Source>() is a structural drift check. It does not derive or
validate:
- Runtime codecs or refinements
- Entity identity or database primary keys
- Length, uniqueness, foreign-key, or check constraints
- Mapping logic between differently shaped fields
Those remain explicit contract decisions. If the source uses created_at
and the public model uses createdAt, map the row in the handler and check
the model against the mapped domain type instead.
This separation is deliberate: runtime reflection would require importing and executing the source module in the browser, while type-only compatibility keeps the model small, inspectable, and bundler-independent.
Views remain the endpoint allowlist
The model shape answers “may this field cross the wire at all?” A view answers
“does this endpoint ship it?” Continue to scope outputs with pick, select,
or an explained all:
export const UserRef = User.pick("id", "name");
export const UserCard = User.pick("id", "name", "avatarUrl");
export const UserSelf = User.all("the viewer is the subject of this record");
Source compatibility does not widen any of those views.