Property Modifiers

Property modifiers in Routier allow you to customize the behavior, constraints, and metadata of your schema properties. They can be chained together to create powerful, flexible schemas that accurately represent your database structure.

Quick Summary

  • Default: Define default values for properties.
  • Deserialize: Custom deserializer (e.g., parse ISO strings to Date).
  • Distinct: Mark property as unique (distinct index).
  • Identity: Mark property as database/computed identity (auto-generated).
  • Index: Define single or composite indexes.
  • Key: Define primary key.
  • Nullable: Allow null.
  • Optional: Allow undefined (omit the field).
  • Readonly: Disallow modification after creation.
  • Serialize: Custom serializer (e.g., Date to ISO string).
  • Tracked: Persist computed value for indexing and faster reads.

Available Modifiers

All schema types support these core modifiers:

  • .optional() - Makes the property optional
  • .nullable() - Allows the property to be null
  • .default(value) - Sets a default value
  • .readonly() - Makes the property read-only
  • .deserialize(fn) - Custom deserialization function
  • .serialize(fn) - Custom serialization function
  • .array() - Converts the property to an array type
  • .index(...names) - Creates database indexes

Additional modifiers are available on specific types:

  • .key() - Marks as primary key (string, number, date)
  • .identity() - Auto-generates values (string, number, date, boolean)
  • .distinct() - Ensures unique values (string, number, date, boolean)

Tracked (for computed values)

.tracked()

Persists a computed value to the underlying store. Use when:

  • You need to index or sort/filter by the computed value
  • Recomputing is expensive and you want to cache post-save
import { s } from "@routier/core/schema";
import { v4 as uuidv4 } from 'uuid';

// Tracked computed properties within schemas
const productSchema = s.define("products", {
    id: s.string().key().identity(),
    name: s.string(),
    price: s.number(),
    category: s.string(),
}).modify(w => ({
    // Computed property with tracking for indexing
    displayName: w.computed((entity, collectionName) =>
        `${entity.name} (${entity.category})`,
        {}
    ).tracked(),

    // Computed with injected dependencies
    slug: w.computed((entity, collectionName, injected) =>
        injected.slugify(entity.name),
        { slugify: (str: string) => str.toLowerCase().replace(/\s+/g, '-') }
    ).tracked(),
})).compile();

Notes:

  • .tracked() applies to computed properties. It does not change the computation, only persistence/indexability.
  • Use .tracked() sparingly; it increases write costs but can greatly improve read performance.
  • Computed function parameters: (entity, collectionName, injected) where entity is the current entity, collectionName is the schema collection name, and injected contains your dependencies.

Identity and Keys

.key()

Marks a property as a primary key for the entity.

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

// Key properties within schemas
const userSchema = s.define("users", {
    // String key
    email: s.string().key(),

    // Number key  
    userId: s.number().key(),

    // Date key
    timestamp: s.date().key(),

    // Key with identity (auto-generated)
    id: s.string().key().identity(),
}).compile();

Available on: string, number, date

.identity()

Marks the property for automatic value generation by the datastore. The datastore will generate unique values for this property.

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

// Identity properties within schemas
const entitySchema = s.define("entities", {
    // String identity (datastore generates UUID)
    id: s.string().key().identity(),

    // Number identity (datastore generates auto-incrementing value)
    userId: s.number().key().identity(),

    // Date identity (datastore generates current timestamp)
    createdAt: s.date().key().identity(),

    // Boolean identity (datastore generates true/false)
    isActive: s.boolean().identity(),
}).compile();

Available on: string, number, date, boolean

Indexing

.index()

Creates a database index for efficient querying.

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

// Indexed properties within schemas
const searchSchema = s.define("search", {
    id: s.string().key().identity(),

    // Single index
    email: s.string().index("email_idx"),

    // Multiple indexes
    category: s.string().index("category_idx", "search_idx"),

    // Distinct index (unique)
    username: s.string().distinct(),

    // Indexed with other modifiers
    status: s.string("active", "inactive").index("status_idx").default("active"),
}).compile();

Available on: All types

Compound Indexes

Multiple fields can share the same index name for compound indexing.

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

// Compound indexes within schemas
const orderSchema = s.define("orders", {
    id: s.string().key().identity(),

    // Multiple fields sharing the same index name for compound indexing
    userId: s.string().index("user_orders"),
    orderDate: s.date().index("user_orders"),
    status: s.string().index("user_orders"),

    // Another compound index
    category: s.string().index("category_status"),
    priority: s.string().index("category_status"),
}).compile();

Defaults and Values

.default()

Sets a default value for the property. Can accept either a direct value or a function that returns a value.

Function Parameters:

  • .default((injected) => value, { injected }) - Function with injected dependencies
  • .default((injected, collectionName) => value, { injected }) - Function with injected dependencies and collection name
import { s } from "@routier/core/schema";
import { v4 as uuidv4 } from 'uuid';

// Default values within schemas
const postSchema = s.define("posts", {
    id: s.string().key().identity(),
    title: s.string(),
    content: s.string(),

    // Static default value
    status: s.string("draft", "published").default("draft"),

    // Function default value
    createdAt: s.date().default(() => new Date()),

    // Default with injected dependencies
    slug: s.string().default((injected) => injected.uuidv4(), { uuidv4 }),

    // Default with collection name context
    collectionType: s.string().default((injected, collectionName) =>
        `post_${collectionName}`,
        {}
    ),
}).compile();

Available on: All types

Note: When using a function, it’s evaluated each time a default is needed, making it perfect for dynamic values like timestamps or context-dependent defaults. The function parameters are (injected, collectionName) where injected contains your dependencies and collectionName is the schema collection name.

Insert semantics

  • If a property has .default(...), it is considered optional during inserts. When the value is omitted, Routier will supply the default.
import { s } from "@routier/core/schema";

// Default values with insert semantics
const userSchema = s.define("users", {
    id: s.string().key().identity(),
    name: s.string(),
    email: s.string().distinct(),

    // Properties with defaults are optional during inserts
    createdAt: s.date().default(() => new Date()),
    isActive: s.boolean().default(true),
    role: s.string("user", "admin").default("user"),
}).compile();

// Usage example - these properties can be omitted during creation
const newUser = {
    name: "John Doe",
    email: "[email protected]",
    // createdAt, isActive, and role will use their defaults
};

Behavior Control

.optional()

Makes the property optional (can be undefined).

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

// Optional properties within schemas
const profileSchema = s.define("profiles", {
    id: s.string().key().identity(),
    name: s.string(),

    // Optional properties (can be undefined)
    bio: s.string().optional(),
    avatar: s.string().optional(),
    website: s.string().optional(),

    // Optional with default
    theme: s.string("light", "dark").optional().default("light"),
}).compile();

Available on: All types

.nullable()

Makes the property nullable (can be null).

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

// Nullable properties within schemas
const dataSchema = s.define("data", {
    id: s.string().key().identity(),
    name: s.string(),

    // Nullable properties (can be null)
    description: s.string().nullable(),
    metadata: s.object({}).nullable(),

    // Nullable with default
    status: s.string().nullable().default(null),

    // Both optional and nullable
    notes: s.string().optional().nullable(),
}).compile();

Available on: All types

.readonly()

Makes the property read-only after creation.

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

// Readonly properties within schemas
const auditSchema = s.define("audit", {
    id: s.string().key().identity(),
    action: s.string(),

    // Readonly properties (cannot be modified after creation)
    createdAt: s.date().readonly().default(() => new Date()),
    createdBy: s.string().readonly(),

    // Readonly with other modifiers
    version: s.number().readonly().default(1),
}).compile();

Available on: All types

Serialization

.serialize()

Custom serialization function for the property.

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

// Serialization within schemas
const configSchema = s.define("config", {
    id: s.string().key().identity(),
    name: s.string(),

    // Custom serialization
    settings: s.object({}).serialize((obj) => JSON.stringify(obj))
        .deserialize((str) => JSON.parse(str)),

    // Date serialization
    lastModified: s.date().serialize((date) => date.toISOString())
        .deserialize((str) => new Date(str)),

    // Number serialization
    count: s.number().serialize((num) => num.toString())
        .deserialize((str) => parseFloat(str)),
}).compile();

Available on: All types

.deserialize()

Custom deserialization function for the property.

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

// Deserialization within schemas
const dataSchema = s.define("data", {
    id: s.string().key().identity(),
    name: s.string(),

    // Custom deserialization
    metadata: s.object({}).deserialize((str) => JSON.parse(str)),

    // Date deserialization
    timestamp: s.date().deserialize((str) => new Date(str)),

    // Number deserialization
    value: s.number().deserialize((str) => parseFloat(str)),
}).compile();

Available on: All types

Type Conversion

.array()

Converts any property type to an array of that type. This allows you to combine base types with array functionality.

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

// Type combination examples within schemas
const combinedSchema = s.define("combined", {
    id: s.string().key().identity(),

    // Combining string with array conversion
    singleTag: s.string().array(), // Converts string to string[]

    // Combining number with array conversion  
    singleScore: s.number().array(), // Converts number to number[]

    // Combining object with array conversion
    singleItem: s.object({
        name: s.string(),
        value: s.number(),
    }).array(), // Converts object to object[]

    // Combining boolean with array conversion
    singleFlag: s.boolean().array(), // Converts boolean to boolean[]

    // Combining date with array conversion
    singleDate: s.date().array(), // Converts date to date[]
}).compile();

Available on: All types

Type Combinations:

  • s.string().array()string[]
  • s.number().array()number[]
  • s.boolean().array()boolean[]
  • s.date().array()Date[]
  • s.object({...}).array()object[]

.distinct()

Ensures the property value is unique across all entities.

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

// Distinct properties within schemas
const userSchema = s.define("users", {
    id: s.string().key().identity(),
    name: s.string(),

    // Distinct properties (unique values)
    email: s.string().distinct(),
    username: s.string().distinct(),

    // Distinct with other modifiers
    phoneNumber: s.string().distinct().optional(),
    employeeId: s.number().distinct(),
}).compile();

Available on: string, number, date, boolean

Chaining Modifiers

Modifiers can be chained together in any order:

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

// Modifier chaining within schemas
const complexSchema = s.define("complex", {
    id: s.string().key().identity(),

    // Recommended order: type -> constraints -> behavior -> defaults
    email: s.string().distinct().optional().default(""),

    // Complex chaining
    age: s.number(18, 19, 20, 21).default(18).readonly(),

    // Serialization with other modifiers
    profile: s.object({
        bio: s.string().optional().nullable(),
        avatar: s.string().optional(),
    }).optional().serialize((obj) => JSON.stringify(obj))
        .deserialize((str) => JSON.parse(str)),

    // Indexed with defaults
    status: s.string("active", "inactive").index("status_idx").default("active"),
}).compile();

Modifier Compatibility

Not all modifiers can be used together. Here are the key rules based on the source code:

Mutually Exclusive Modifiers

  • .key() and .optional() - Cannot be used together (keys are always required)
  • .identity() and .default() - Cannot be used together (identity generates values)
  • .optional() and .nullable() - Can be used together

Modifier Support by Type

Modifier string number boolean date object array
.optional()
.nullable()
.default()
.readonly()
.serialize()
.deserialize()
.array()
.index()
.key()
.identity()
.distinct()

Modifier Order

While modifiers can be chained in any order, it’s recommended to follow this pattern:

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

// Modifier order examples within schemas
const orderSchema = s.define("orders", {
    // 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();

Best Practices

1. Use Built-in Modifiers

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

// Built-in modifiers examples within schemas
const bestPracticeSchema = s.define("bestPractice", {
    id: s.string().key().identity(),

    // Use built-in modifiers instead of custom logic
    email: s.string().distinct(), // Instead of custom uniqueness validation
    createdAt: s.date().default(() => new Date()), // Instead of manual timestamp setting
    isActive: s.boolean().default(true), // Instead of conditional logic

    // Define constraints early
    status: s.string("pending", "approved", "rejected").default("pending"),
    priority: s.number(1, 2, 3, 4, 5).default(1),
}).compile();

2. Define Constraints Early

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

// Constraints defined early within schemas
const earlyConstraintsSchema = s.define("earlyConstraints", {
    id: s.string().key().identity(),

    // Define constraints early in the schema
    email: s.string().distinct(), // Unique constraint
    username: s.string().distinct().index("username_idx"), // Unique + indexed
    role: s.string("admin", "user", "guest").default("user"), // Literal constraint

    // Then add behavior modifiers
    isActive: s.boolean().default(true),
    lastLogin: s.date().optional(),
}).compile();

3. Leverage Type Safety

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

// Type safety examples within schemas
const typeSafeSchema = s.define("typeSafe", {
    id: s.string().key().identity(),
    name: s.string(),
    email: s.string().distinct(),
    age: s.number().default(18),
}).compile();

// Leverage TypeScript type safety
type User = InferType<typeof typeSafeSchema>;
type CreateUser = InferCreateType<typeof typeSafeSchema>;

// Type-safe function parameters
function createUser(userData: CreateUser): User {
    // TypeScript will enforce correct types
    return userData as User;
}

// Type-safe API responses
function getUser(): User {
    return {
        id: "user-123",
        name: "John Doe",
        email: "[email protected]",
        age: 30,
    };
}

4. Use Appropriate Modifiers

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

// Appropriate modifiers examples within schemas
const appropriateSchema = s.define("appropriate", {
    id: s.string().key().identity(),

    // Use appropriate modifiers for the use case
    email: s.string().distinct(), // For unique email addresses
    username: s.string().distinct().index("username_idx"), // For searchable usernames
    createdAt: s.date().default(() => new Date()).readonly(), // For audit timestamps
    isActive: s.boolean().default(true), // For status flags
    metadata: s.object({}).optional().serialize(obj => JSON.stringify(obj)), // For flexible data

    // Avoid over-engineering
    // Don't use .distinct() on every field
    // Don't use .readonly() unless you need immutability
    // Don't use .serialize() unless you need custom format
}).compile();

Next Steps