Skip to content

Creating A Schema

Schemas in Routier define the structure and behavior of your data entities. The schema builder provides a fluent, type-safe API for creating robust data schemas.

Quick Navigation

Basic Schema Definition

ts
import { s } from "@routier/core/schema";

// Basic schema definition
const userSchema = s
    .define("users", {
        id: s.string().key().identity(),
        email: s.string().distinct(),
        name: s.string(),
        createdAt: s.date().default(() => new Date()),
    })
    .compile();

Schema Builder API

The s object provides the main entry point for schema creation:

Core Functions

  • s.define(collectionName, schema) - Creates a schema definition
  • s.number<T>() - Number property with optional literal constraints
  • s.string(...literals) - String property with optional literal constraints
  • s.string({ maxLength }, ...literals) - String plus a storage length declaration
  • s.boolean<T>() - Boolean property
  • s.date<T>() - Date property
  • s.array(schema) - Array property containing another schema property
  • s.object(schema) - Object property with nested schema
  • s.file() - File input/reference output used with @routier/blob-plugin
  • s.vector(dimensions) - Fixed-width numeric embedding used with .nearest()

Literal Type Constraints

You can constrain properties to specific literal values:

ts
import { s } from "@routier/core/schema";

// Literal type constraints
const statusSchema = s.define("orders", {
    id: s.string().key().identity(),
    status: s.string("pending", "processing", "completed", "cancelled"),
    priority: s.number(1, 2, 3, 4, 5),
    isActive: s.boolean(),
}).compile();

Property Modifiers

Each schema type supports a set of modifiers that can be chained together:

Core Modifiers

ts
import { s } from "@routier/core/schema";

// Core modifiers
const productSchema = s.define("products", {
    id: s.string().key().identity(),
    name: s.string().optional().nullable(),
    price: s.number().default(0),
    description: s.string().readonly(),
    category: s.string().distinct(),
    createdAt: s.date().default(() => new Date()),
}).compile();

Serialization Modifiers

ts
import { s } from "@routier/core/schema";

// Serialization modifiers
const configSchema = s.define("config", {
    id: s.string().key().identity(),
    settings: s.object({
        theme: s.string(),
        notifications: s.boolean(),
    }).serialize((obj) => JSON.stringify(obj))
        .deserialize((str) => JSON.parse(str)),
    metadata: s.array(s.string()).serialize((arr) => arr.join(","))
        .deserialize((str) => str.split(",")),
}).compile();

Array and Object Modifiers

ts
import { s } from "@routier/core/schema";

// Array and object modifiers
const companySchema = s.define("companies", {
    id: s.string().key().identity(),
    name: s.string(),
    address: s.object({
        street: s.string(),
        city: s.string(),
        zipCode: s.string(),
    }).optional(),
    departments: s.array(s.string()).default([]),
    employees: s.array(s.object({
        name: s.string(),
        role: s.string(),
        startDate: s.date(),
    })).optional(),
}).compile();

Complete Example

ts
import { s } from "@routier/core/schema";

// Complete example with all features
const blogPostSchema = s.define("blogPosts", {
    id: s.string().key().identity(),
    title: s.string().distinct(),
    content: s.string(),
    author: s.string(),
    tags: s.array(s.string()).default([]),
    status: s.string("draft", "published", "archived").default("draft"),
    publishedAt: s.date().optional(),
    metadata: s.object({
        views: s.number().default(0),
        likes: s.number().default(0),
        comments: s.array(s.object({
            author: s.string(),
            content: s.string(),
            createdAt: s.date().default(() => new Date()),
        })).default([]),
    }).default({}),
    createdAt: s.date().default(() => new Date()),
    updatedAt: s.date().default(() => new Date()),
}).compile();

Modifier Chaining

Modifiers can be chained in any order, but it's recommended to follow a logical pattern:

ts
import { s } from "@routier/core/schema";

// Modifier chaining examples
const chainedSchema = s.define("examples", {
    // Recommended order: type -> constraints -> behavior -> defaults
    id: s.string().key().identity(),
    email: s.string().distinct().optional(),
    age: s.number(18, 19, 20, 21).default(18),
    status: s.string("active", "inactive").default("active").readonly(),

    // Complex chaining
    profile: s.object({
        bio: s.string().optional().nullable(),
        avatar: s.string().optional(),
    }).optional().serialize((obj) => JSON.stringify(obj))
        .deserialize((str) => JSON.parse(str)),
}).compile();

Compiling Schemas

Always call .compile() at the end to create the final schema:

ts
import { InferType, s } from "@routier/core/schema";

// Compiling schemas
const userSchema = s.define("users", {
    id: s.string().key().identity(),
    name: s.string(),
    email: s.string().distinct(),
}).compile();

// The compiled schema is ready to use.
//
// To get the ENTITY type, wrap it in InferType. `typeof userSchema` is the type of the
// schema object itself — it has no `id`, `name` or `email` on it.
type User = InferType<typeof userSchema>;

// User is { id: string; name: string; email: string }
const user: User = { id: "1", name: "James", email: "[email protected]" };

Getting The Type Out

A compiled schema already describes the shape of your data, so you never write that shape a second time. InferType reads the entity type back off the schema, the way z.infer does in Zod:

ts
type Product = InferType<typeof productSchema>;

Note that typeof productSchema on its own is the type of the schema object, not the entity — it has no name or price on it. InferType is what unwraps it.

There is a second one worth knowing. InferCreateType is the shape you pass when adding an entity, which omits identity properties and anything with a default, because the store fills those in:

ts
import { InferCreateType, InferType, s } from "@routier/core/schema";

const productSchema = s.define("products", {
    id: s.string().key().identity(),
    name: s.string(),
    category: s.string("tool", "material"),
    price: s.number(),
    createdAt: s.date().default(() => new Date()),
}).compile();

// Declare the shape once, in the schema. These read it back off.
export type Product = InferType<typeof productSchema>;
export type NewProduct = InferCreateType<typeof productSchema>;

// Product is the stored entity: every property present, unions preserved.
const stored: Product = {
    id: "p1",
    name: "Hammer",
    category: "tool",
    price: 12,
    createdAt: new Date(),
};

// NewProduct is what you pass to addAsync: `id` is generated and `createdAt` has a
// default, so neither is required here.
const toAdd: NewProduct = {
    name: "Oak plank",
    category: "material",
    price: 4,
};

// Use them anywhere a type is expected — parameters, returns, React props, API payloads.
const priceOf = (product: Product): number => product.price;

// A schema change is a compile error everywhere the type is used, instead of a silent
// mismatch against a hand-written interface.
export { productSchema, stored, toAdd, priceOf };

Reach for these instead of hand-writing an interface. A hand-written one has to be updated every time the schema changes, and nothing tells you when you have missed one.

See InferType for the full reference.

Extending A Schema With .modify()

.modify() runs after define() and adds properties that are derived from the entity rather than supplied by the caller. It receives three builders:

BuilderStored?You get
x.computed(fn)NoThe value fn returns, recomputed on read
x.computed(fn).tracked()YesThe same value, persisted so the backend can filter and index it
x.function(fn)NoWhatever fn returns — return a function to get a method on the entity
x.transform({ to, from, stores })Stored, convertedAn existing property whose stored form differs from its in-memory form

fn receives (entity, collectionName, injected). The collectionName argument is why documentType: x.computed((_, collectionName) => collectionName).tracked() is a common idiom — it tags rows when several collections share one physical store.

ts
import { InferType, s } from "@routier/core/schema";

const orderSchema = s.define("orders", {
    id: s.string().key().identity(),
    quantity: s.number(),
    unitPrice: s.number(),
}).modify(x => ({
    // Derived on read, never stored. The database has no `total` column.
    total: x.computed(order => order.quantity * order.unitPrice),

    // Derived AND stored, so the backend can filter and index on it.
    storedTotal: x.computed(order => order.quantity * order.unitPrice).tracked(),

    // The collection name, which `.modify()` passes as the second argument. A common way to
    // tag rows when several collections share one physical store.
    documentType: x.computed((_order, collectionName) => collectionName).tracked(),

    // Behavior rather than data: the outer function receives the entity, and whatever it
    // RETURNS becomes the property. Return a function and you get a method on the entity.
    describe: x.function(order => () => `${order.quantity} x ${order.unitPrice}`),
})).compile();

// All of it is part of the inferred type, so nothing needs restating by hand.
type Order = InferType<typeof orderSchema>;

const report = (order: Order) => ({
    total: order.total,             // number, computed on read
    stored: order.storedTotal,      // number, read back from storage
    kind: order.documentType,       // "orders"
    text: order.describe(),         // "2 x 9.99"
});

export { orderSchema, report };
export type { Order };

Two things worth being clear about:

computed versus computed().tracked() is about the database, not the type. Both give you the same property to read. Only the tracked one exists as a column, so only the tracked one can be filtered or sorted in the backend rather than in memory.

x.function holds whatever your function returns. Returning a value gives you a value; returning a function gives you a method, which is the describe() case above. Unlike computed, it can never be .tracked() — behavior is not storable.

x.transform replaces an existing property under the same name, declaring both directions of a storage conversion. See Schema API for its options, and Encryption for a packaged one.

Next Steps

Released under the MIT License.