Skip to content

Schema DSL ​

WarpDrive offers a TypeScript based compile-time DSL for producing JSON resource schemas. Using JSON for the output ensures flexibility, composability, and interoperability, while using TypeScript for authoring syntax provides a convenient DX.

💡Read More

This blog post (In Defense of Machine Formats) covers the motivations behind using JSON in more depth.

Overview ​

The Schema DSL uses TypeScript class syntax, type annotations, and decorators to author resource schemas that compile to WarpDrive's JSON schema format. This approach provides:

  • Type Safety: Full TypeScript support for read, create, and edit operations
  • IDE Support: Autocomplete, refactoring, and type checking
  • Compile-Time Generation: Schemas are generated at build time
  • Runtime Flexibility: Produces standard JSON schemas for runtime use

Quick Start ​

Here's a simple resource schema using the DSL:

typescript
import { Resource, field } from '@warp-drive/schema-dsl';

@Resource
class User {
  @field declare firstName: string;
  @field declare lastName: string;
  @field declare email: string;
}

This generates the equivalent JSON schema:

typescript
{
  type: 'user', // derived from class name
  identity: { kind: '@id', name: '@id' },
  fields: [
    { kind: 'field', name: 'firstName' },
    { kind: 'field', name: 'lastName' },
    { kind: 'field', name: 'email' }
  ]
}

The @Resource decorator automatically derives the type name from the class name (User → 'user'). You can override this by providing an explicit type: @Resource('custom-type').

Base Classes and Decorators ​

@Resource Decorator ​

The @Resource decorator marks a class as a resource schema with unique identity.

typescript
@Resource
@Resource(type: string)
@Resource(options: ResourceOptions)
@Resource(type: string, options: ResourceOptions)

Parameters:

  • type (optional): Override the resource type name. If omitted, derives from class name:
    • User → 'user'
    • UserPost → 'user-post'
    • APIKey → 'api-key'
  • options: Optional configuration
    • legacy?: boolean: Enable LegacyMode for compatibility with @warp-drive/legacy/model
    • identityField?: string: Custom identity field name (default: '@id')

Examples:

typescript
// Derived type name from class name
@Resource
class User {
  // type: 'user'
}

@Resource
class UserProfile {
  // type: 'user-profile'
}

// Explicit type name override
@Resource('custom-user')
class User {
  // type: 'custom-user'
}

// Legacy mode resource
@Resource({ legacy: true })
class Post {
  // type: 'post', legacy mode
}

// Both type override and options
@Resource('article', { identityField: 'uuid' })
class Article {
  // type: 'article', custom identity field
}

@Object Decorator ​

The @Object decorator marks a class as an object schema (embedded structure with no independent identity).

typescript
@Object
@Object(type: string)
@Object(options: ObjectOptions)
@Object(type: string, options: ObjectOptions)

Parameters:

  • type (optional): Override the object type name. If omitted, derives from class name (same rules as @Resource)
  • options: Optional configuration
    • hash?: boolean: Include hash field for computed identity (default: false)

Examples:

typescript
// Derived type name from class name
@Object
class Address {
  // type: 'address'
  @field declare street: string;
  @field declare city: string;
  @field declare zipCode: string;
}

// Explicit type name
@Object('geo-coordinate')
class Coordinate {
  @field declare latitude: number;
  @field declare longitude: number;
}

// Object with hash identity
@Object({ hash: true })
class Coordinate {
  @field declare latitude: number;
  @field declare longitude: number;
}

Field Decorators ​

All field decorators follow the pattern:

typescript
@decorator(options?: FieldOptions)
declare propertyName: Type;

@field ​

Basic field for primitive values (strings, numbers, booleans, dates).

typescript
@field
@field(options: {
  sourceKey?: string;
  type?: string;
})

Options:

  • sourceKey: Alternative name in the cache if different from property name
  • type: Transform name (e.g., 'date-time', 'date', 'number')

Examples:

typescript
@Resource
class User {
  @field declare firstName: string;
  @field declare age: number;
  @field declare isActive: boolean;

  @field({ type: 'date-time' })
  declare createdAt: Date;

  @field({ sourceKey: 'email_address' })
  declare email: string;
}

@id ​

Marks the identity field for a resource. Typically not needed as WarpDrive provides a default @id field.

typescript
@id(options?: {
  sourceKey?: string;
})

Example:

typescript
@Resource('user', { identityField: 'uuid' })
class User {
  @id declare uuid: string;

  @field declare name: string;
}

@local ​

Fields that exist only in local state, not persisted to cache.

typescript
@local
@local(options: {
  defaultValue?: any;
})

Examples:

typescript
@Resource
class User {
  @field declare name: string;
  @local declare isDestroyed: boolean;
  @local declare isDestroying: boolean;

  @local({ defaultValue: false })
  declare isDirty: boolean;
}

@hash ​

Defines a hash field for computing identity of object schemas.

typescript
@hash(options?: {
  type: string; // Hash function name
})

Example:

typescript
@Object('address', { hash: true })
class Address {
  @hash({ type: 'address-hash' })
  declare $hash: string;

  @field declare street: string;

  @field declare city: string;
}

@object ​

Unstructured object field containing arbitrary key-value pairs with primitive values.

typescript
@object
@object(options: {
  sourceKey?: string;
  objectExtensions?: string[];
})

Examples:

typescript
@Resource
class User {
  @field declare name: string;
  @object declare metadata: Record<string, string | number | boolean>;

  @object({ objectExtensions: ['legacy-metadata'] })
  declare settings: Record<string, unknown>;
}

@array ​

Array field containing primitive values.

typescript
@array
@array(options: {
  sourceKey?: string;
  arrayExtensions?: string[];
})

Examples:

typescript
@Resource
class Post {
  @field declare title: string;
  @array declare tags: string[];
  @array declare viewCounts: number[];
}

@schemaObject ​

Embedded structured object with named fields defined by an ObjectSchema.

typescript
@schemaObject(options: {
  type: string;
  sourceKey?: string;
  polymorphic?: boolean;
  typeField?: string;
})

Parameters:

  • type: The object schema type name
  • sourceKey: Alternative cache key
  • polymorphic: Allow multiple types (default: false)
  • typeField: Discriminator field for polymorphic objects (default: 'type')

Examples:

typescript
@Object('address')
class Address {
  @field declare street: string;

  @field declare city: string;
}

@Resource
class User {
  @field declare name: string;

  @schemaObject({ type: 'address' })
  declare address: Address;
}

// Polymorphic schema object
@Resource('post')
class Post {
  @field declare title: string;

  @schemaObject({
    type: 'content',
    polymorphic: true,
    typeField: 'contentType'
  })
  declare content: TextContent | VideoContent | ImageContent;
}

@schemaArray ​

Array of structured objects with named fields.

typescript
@schemaArray(options: {
  type: string;
  sourceKey?: string;
  polymorphic?: boolean;
  typeField?: string;
  key?: '@identity' | '@index' | '@hash' | string;
  defaultValue?: boolean;
})

Parameters:

  • type: The object schema type name
  • key: Strategy for identifying array elements
    • '@identity': Elements have unique identity
    • '@index': Use array index
    • '@hash': Compute hash from content
    • string: Use specific field as key
  • defaultValue: Initialize with empty array (default: false)
  • polymorphic: Allow multiple types
  • typeField: Discriminator field for polymorphic arrays

Examples:

typescript
@Object('translation')
class Translation {
  @field declare locale: string;

  @field declare text: string;
}

@Resource('post')
class Post {
  @field declare title: string;

  @schemaArray({
    type: 'translation',
    key: 'locale'
  })
  declare translations: Translation[];
}

// With default value
@Resource
class User {
  @field declare name: string;

  @schemaArray({
    type: 'phoneNumber',
    key: '@index',
    defaultValue: true
  })
  declare phoneNumbers: PhoneNumber[];
}

@derived ​

Computed read-only field that is not stored in cache.

typescript
@derived(options: {
  type: string; // Derivation function name
})

Examples:

typescript
@Resource
class User {
  @field declare firstName: string;

  @field declare lastName: string;

  @derived({ type: 'fullName' })
  declare fullName: string;
}

// Register derivation
schema.registerDerivation({
  type: 'fullName',
  options: {},
  derive(record: User) {
    return `${record.firstName} ${record.lastName}`;
  }
});

@alias ​

Field aliasing for renaming or transforming cache fields.

typescript
@alias(options: {
  sourceKey: string;
  type?: string;
})

Examples:

typescript
@Resource
class User {
  @alias({ sourceKey: 'first_name' })
  declare firstName: string;

  @alias({ sourceKey: 'last_name' })
  declare lastName: string;

  @alias({
    sourceKey: 'created_at',
    type: 'date-time'
  })
  declare createdAt: Date;
}

Legacy Relationship Fields ​

For LegacyMode resources only:

@attribute ​

typescript
@attribute
@attribute(options: {
  sourceKey?: string;
  type?: string;
})

@belongsTo ​

typescript
@belongsTo(options: {
  type: string;
  async?: boolean;
  inverse: string | null;
  sourceKey?: string;
})

Examples:

typescript
@Resource({ legacy: true })
class Comment {
  @field declare text: string;

  @belongsTo({
    type: 'user',
    async: false,
    inverse: 'comments'
  })
  declare author: User;

  @belongsTo({
    type: 'post',
    async: true,
    inverse: null
  })
  declare post: Post;
}

@hasMany ​

typescript
@hasMany(options: {
  type: string;
  async?: boolean;
  inverse: string | null;
  sourceKey?: string;
})

Examples:

typescript
@Resource('user', { legacy: true })
class User {
  @field declare name: string;

  @hasMany({
    type: 'post',
    async: true,
    inverse: 'author'
  })
  declare posts: Post[];

  @hasMany({
    type: 'comment',
    async: false,
    inverse: 'author'
  })
  declare comments: Comment[];
}

Traits ​

Traits provide reusable field collections for polymorphic composition.

Defining Traits ​

Use the @Trait decorator to define a trait:

typescript
@Trait(name: string, options?: TraitOptions)

Parameters:

  • name: Trait identifier
  • options:
    • mode?: 'legacy' | 'polaris': Trait mode (default: 'polaris')

Examples:

typescript
// Timestamped trait
@Trait('timestamped')
class Timestamped {
  @field({ type: 'date-time' })
  declare createdAt: Date;

  @field({ type: 'date-time' })
  declare updatedAt: Date;
}

// Auditable trait
@Trait('auditable')
class Auditable {
  @field declare createdBy: string;

  @field declare updatedBy: string;
}

// Legacy trait
@Trait('softDelete', { mode: 'legacy' })
class SoftDelete {
  @field({ type: 'date-time' })
  declare deletedAt: Date | null;

  @local declare isDeleted: boolean;
}

Applying Traits ​

Use the @trait decorator (lowercase) to apply traits to resources:

typescript
@trait(...traits: (typeof TraitClass)[])

Examples:

typescript
// Single trait
@Resource
@trait(Timestamped)
class User {
  @field declare name: string;

  // Inherits createdAt and updatedAt from Timestamped
}

// Multiple traits
@Resource
@trait(Timestamped, Auditable)
class Post {
  @field declare title: string;
  @field declare content: string;

  // Inherits:
  // - createdAt, updatedAt from Timestamped
  // - createdBy, updatedBy from Auditable
}

// Trait composition (traits can use other traits)
@Trait('versionable')
@trait(Timestamped, Auditable)
class Versionable {
  @field declare version: number;

  // Inherits from Timestamped and Auditable
}

@Resource
@trait(Versionable)
class Document {
  @field declare title: string;

  // Inherits all fields from Versionable chain
}

Trait Resolution Rules ​

  1. Post-Order Traversal: Traits are processed recursively before the resource
  2. Left-to-Right: Earlier traits are processed first
  3. Last Wins: Later traits override earlier ones, resource fields override all
  4. Cycle Detection: Circular trait dependencies are detected in development mode

Example:

typescript
@Trait('A')
class TraitA {
  @field declare fieldA: string;
  @field declare shared: string; // Will be overridden
}

@Trait('B')
class TraitB {
  @field declare fieldB: string;
  @field declare shared: string; // Will be overridden
}

@Resource
@trait(TraitA, TraitB)
class Example {
  @field declare shared: string; // Wins

  // Final fields: fieldA, fieldB, shared (from Example)
}

Type Generation ​

The DSL generates three types for each resource:

1. Read Type (Immutable) ​

The base type for reading data from the store.

typescript
@Resource
class User {
  @field declare firstName: string;

  @field declare lastName: string;

  @derived({ type: 'fullName' })
  declare fullName: string;
}

// Generated type: User
type UserRead = User;

const user: User = store.peekRecord('user', '1');
console.log(user.firstName); // string
console.log(user.fullName);  // string (derived, read-only)
// user.firstName = 'new'; // Error: readonly

2. Create Type (Writable Fields Only) ​

Type for creating new resources.

typescript
// Generated type: CreateUser
type CreateUser = {
  firstName: string;
  lastName: string;
  // Note: fullName is omitted (derived fields not settable)
  // Note: @id is omitted (generated by server)
}

const newUser = store.createRecord<User>('user', {
  firstName: 'John',
  lastName: 'Doe'
});

3. Edit Type (Editable Fields) ​

Type for editing existing resources.

typescript
// Generated type: EditUser
type EditUser = {
  firstName?: string;
  lastName?: string;
  // Note: fullName is omitted (derived, read-only)
  // Note: all fields are optional for partial updates
}

const user = store.peekRecord('user', '1');
user.edit((draft: EditUser) => {
  draft.firstName = 'Jane';
  // draft.fullName = 'test'; // Error: readonly/doesn't exist
});

Type Modifiers ​

Control field mutability with decorator modifiers. These decorators can be stacked with field decorators to control when fields can be set.

@readonly ​

Fields that cannot be created or edited (server-managed only):

typescript
import { readonly } from '@warp-drive/schema-dsl';

@Resource
class Post {
  @field declare title: string;

  @readonly
  @field({ type: 'date-time' })
  declare createdAt: Date;

  @readonly
  @field declare viewCount: number;
}

// CreatePost: { title: string } - createdAt and viewCount omitted
// EditPost: { title?: string } - createdAt and viewCount omitted

@optional ​

Fields that may be omitted during creation (but required if editing):

typescript
import { optional } from '@warp-drive/schema-dsl';

@Resource
class User {
  @field declare email: string;

  @optional
  @field declare bio: string;
}

// CreateUser: { email: string, bio?: string }
// EditUser: { email?: string, bio?: string }

@createonly ​

Fields that can only be set during creation, not edited:

typescript
import { createonly } from '@warp-drive/schema-dsl';

@Resource
class Post {
  @createonly
  @field declare slug: string;

  @field declare title: string;
}

// CreatePost: { slug: string, title: string }
// EditPost: { title?: string } - slug omitted

@editonly ​

Fields that can only be set during editing, not creation:

typescript
import { editonly } from '@warp-drive/schema-dsl';

@Resource
class Document {
  @field declare content: string;

  @editonly
  @field({ type: 'date-time' })
  declare lastEditedAt: Date;
}

// CreateDocument: { content: string } - lastEditedAt omitted
// EditDocument: { content?: string, lastEditedAt?: Date }

Combining Modifiers ​

Modifiers can be combined for complex scenarios:

typescript
@Resource
class BlogPost {
  // Required during create, can be edited
  @field declare title: string;

  // Optional during create, can be edited
  @optional
  @field declare excerpt: string;

  // Can only be set during create
  @createonly
  @field declare slug: string;

  // Server-managed, never set by client
  @readonly
  @field({ type: 'date-time' })
  declare publishedAt: Date;
}

// CreateBlogPost: { title: string, excerpt?: string, slug: string }
// EditBlogPost: { title?: string, excerpt?: string }

Modes ​

The DSL supports both Polaris (modern) and Legacy modes.

PolarisMode (Default) ​

Modern immutable records with isolated editing.

typescript
@Resource
class User {
  @field declare name: string;

  @field declare email: string;
}

const user = store.peekRecord<User>('user', '1');
console.log(user.name); // Immutable read

user.edit((draft) => {
  draft.name = 'New Name'; // Mutable draft
  draft.email = '[email protected]';
});

Features:

  • Immutable records by default
  • Explicit editing via .edit()
  • No async relationships (use LinksMode)
  • Type-safe with generated types

LegacyMode ​

Compatibility mode for @warp-drive/legacy/model.

typescript
@Resource('user', { legacy: true })
class User {
  @field declare name: string;

  @field declare email: string;

  @belongsTo({ type: 'organization', async: true, inverse: null })
  declare organization: Organization;

  @hasMany({ type: 'post', async: true, inverse: 'author' })
  declare posts: Post[];

  @local declare isDestroyed: boolean;

  @local declare isDestroying: boolean;
}

const user = store.peekRecord<User>('user', '1');
user.name = 'New Name'; // Direct mutation
const posts = await user.posts; // Async relationship

Features:

  • Mutable records
  • Async relationships (belongsTo, hasMany)
  • State properties (isDestroyed, isDestroying, etc.)
  • Full Model API compatibility
  • Requires @warp-drive/legacy package

Choosing a Mode ​

Use PolarisMode for new projects:

  • Better performance
  • Immutability benefits
  • Modern API
  • Future-proof

Use LegacyMode when:

  • Migrating from Ember Data
  • Need async relationships
  • Require Model API compatibility

Extensions ​

Extensions provide temporary behavioral enhancements during migration.

Registering Extensions ​

typescript
schema.CAUTION_MEGA_DANGER_ZONE_registerExtension({
  type: 'legacy-array',
  extend(arr: unknown[]) {
    // Add methods or properties to arrays
  }
});

Using Extensions ​

Apply extensions in field decorators:

typescript
@Resource
class User {
  @array({ arrayExtensions: ['legacy-array'] })
  declare tags: string[];

  @object({ objectExtensions: ['legacy-metadata'] })
  declare metadata: Record<string, unknown>;
}

Object-Level Extensions ​

Apply extensions to entire object schemas:

typescript
@Object('address', { extensions: ['legacy-address'] })
class Address {
  @field declare street: string;
}

Advanced Patterns ​

Polymorphic Relationships ​

Handle multiple types in a single field:

typescript
@Object('textContent')
class TextContent {
  @field declare type: 'text';

  @field declare text: string;
}

@Object('videoContent')
class VideoContent {
  @field declare type: 'video';

  @field declare url: string;

  @field declare duration: number;
}

@Object('imageContent')
class ImageContent {
  @field declare type: 'image';

  @field declare url: string;

  @field declare alt: string;
}

@Resource('post')
class Post {
  @field declare title: string;

  @schemaObject({
    type: 'content',
    polymorphic: true,
    typeField: 'type'
  })
  declare content: TextContent | VideoContent | ImageContent;
}

// Usage
const post = store.peekRecord<Post>('post', '1');
if (post.content.type === 'video') {
  console.log(post.content.duration); // Type narrowing works
}

Schema Arrays with Keys ​

Different strategies for identifying array elements:

typescript
@Object('phoneNumber')
class PhoneNumber {
  @field declare type: string;

  @field declare number: string;
}

@Resource
class User {
  // Use field as key
  @schemaArray({
    type: 'phoneNumber',
    key: 'type'
  })
  declare phoneNumbers: PhoneNumber[];
}

// Array operations maintain identity by 'type' field
user.edit((draft) => {
  draft.phoneNumbers = [
    { type: 'mobile', number: '555-1234' },
    { type: 'home', number: '555-5678' }
  ];
});

Nested Object Schemas ​

typescript
@Object('coordinate')
class Coordinate {
  @field declare latitude: number;

  @field declare longitude: number;
}

@Object('address')
class Address {
  @field declare street: string;

  @field declare city: string;

  @schemaObject({ type: 'coordinate' })
  declare location: Coordinate;
}

@Resource('venue')
class Venue {
  @field declare name: string;

  @schemaObject({ type: 'address' })
  declare address: Address;
}

// Usage
const venue = store.peekRecord<Venue>('venue', '1');
console.log(venue.address.location.latitude);

Transformations ​

Register and use custom transformations:

typescript
// Register transformation
schema.registerTransformation({
  type: 'currency',
  serialize(value: number): string {
    return `$${(value / 100).toFixed(2)}`;
  },
  hydrate(value: string): number {
    return Math.round(parseFloat(value.slice(1)) * 100);
  }
});

// Use in schema
@Resource('product')
class Product {
  @field declare name: string;

  @field({ type: 'currency' })
  declare price: number; // Stored as cents, displayed as dollars
}

Derivations ​

Create computed fields:

typescript
// Register derivation
schema.registerDerivation({
  type: 'fullName',
  options: {},
  derive(record: User, options: {}, prop: string) {
    return `${record.firstName} ${record.lastName}`;
  }
});

// Use in schema
@Resource
class User {
  @field declare firstName: string;

  @field declare lastName: string;

  @derived({ type: 'fullName' })
  declare fullName: string;
}

Hash Functions ​

Compute identity for object schemas:

typescript
// Register hash function
schema.registerHashFn({
  type: 'coordinate-hash',
  compute(obj: Coordinate): string {
    return `${obj.latitude},${obj.longitude}`;
  }
});

// Use in schema
@Object('coordinate', { hash: true })
class Coordinate {
  @hash({ type: 'coordinate-hash' })
  declare $hash: string;

  @field declare latitude: number;

  @field declare longitude: number;
}

Best Practices ​

Schema Organization ​

typescript
// schemas/traits/timestamped.ts
@Trait('timestamped')
export class Timestamped {
  @field({ type: 'date-time' })
  declare createdAt: Date;

  @field({ type: 'date-time' })
  declare updatedAt: Date;
}

// schemas/objects/address.ts
@Object('address')
export class Address {
  @field declare street: string;

  @field declare city: string;

  @field declare zipCode: string;
}

// schemas/resources/user.ts
import { Timestamped } from '../traits/timestamped';
import { Address } from '../objects/address';

@Resource
@trait(Timestamped)
export class User {
  @field declare name: string;

  @field declare email: string;

  @schemaObject({ type: 'address' })
  declare address: Address;
}

Field Naming Conventions ​

typescript
@Resource
class User {
  // Prefer camelCase for JavaScript
  @field declare firstName: string;

  // Use sourceKey for snake_case APIs
  @field({ sourceKey: 'phone_number' })
  declare phoneNumber: string;

  // Prefix hash fields with $
  @hash({ type: 'user-hash' })
  declare $hash: string;

  // Prefix derived fields with $ or use descriptive names
  @derived({ type: 'fullName' })
  declare fullName: string;
}

Type Safety ​

typescript
// Export types for external use
@Resource
export class User {
  @field declare name: string;

  @field declare email: string;
}

// Use generated types
export type UserRead = User;
export type UserCreate = CreateUser;
export type UserEdit = EditUser;

// In application code
function displayUser(user: UserRead) {
  console.log(user.name);
}

function updateUser(id: string, updates: UserEdit) {
  const user = store.peekRecord<User>('user', id);
  user.edit((draft) => {
    Object.assign(draft, updates);
  });
}

Avoid Over-Engineering ​

typescript
// Good: Simple fields for simple data
@Resource
class User {
  @field declare name: string;

  @object declare settings: Record<string, string>;
}

// Avoid: Over-structured for simple data
@Object('setting')
class Setting {
  @field declare key: string;

  @field declare value: string;
}

@Resource
class User {
  @field declare name: string;

  @schemaArray({ type: 'setting', key: 'key' })
  declare settings: Setting[];
}

Migration Guide ​

From JSON Schemas ​

Before (JSON):

typescript
import { withDefaults } from '@warp-drive/core/reactive';

export const UserSchema = withDefaults({
  type: 'user',
  fields: [
    { kind: 'field', name: 'firstName' },
    { kind: 'field', name: 'lastName' },
    { kind: 'field', name: 'email' },
    {
      kind: 'schema-object',
      name: 'address',
      type: 'address'
    }
  ],
  traits: ['timestamped']
});

After (DSL):

typescript
import { Resource, field, schemaObject, trait } from '@warp-drive/schema-dsl';
import { Timestamped } from '../traits/timestamped';

@Resource
@trait(Timestamped)
export class User {
  @field declare firstName: string;

  @field declare lastName: string;

  @field declare email: string;

  @schemaObject({ type: 'address' })
  declare address: Address;
}

From Ember Data Models ​

Before (Ember Data):

typescript
import Model, { attr, belongsTo, hasMany } from '@ember-data/model';

export default class UserModel extends Model {
  @attr('string') firstName;
  @attr('string') lastName;
  @attr('string') email;
  @belongsTo('organization', { async: true, inverse: null }) organization;
  @hasMany('post', { async: true, inverse: 'author' }) posts;
}

After (DSL):

typescript
import { Resource, field, belongsTo, hasMany } from '@warp-drive/schema-dsl';

@Resource('user', { legacy: true })
export class User {
  @field declare firstName: string;

  @field declare lastName: string;

  @field declare email: string;

  @belongsTo({ type: 'organization', async: true, inverse: null })
  declare organization: Organization;

  @hasMany({ type: 'post', async: true, inverse: 'author' })
  declare posts: Post[];
}

API Reference ​

Decorators ​

DecoratorPurposeApplies To
@Resource / @Resource(type?, options?)Define resource schemaClass
@Object / @Object(type?, options?)Define object schemaClass
@Trait(name, options?)Define traitClass
@trait(...traits)Apply traitsClass
@field / @field(options?)Basic fieldProperty
@id / @id(options?)Identity fieldProperty
@local / @local(options?)Local-only fieldProperty
@hash(options)Hash fieldProperty
@object / @object(options?)Unstructured objectProperty
@array / @array(options?)Primitive arrayProperty
@schemaObject(options)Structured objectProperty
@schemaArray(options)Structured arrayProperty
@derived(options)Computed fieldProperty
@alias(options)Field aliasProperty
@attribute / @attribute(options?)Legacy attributeProperty
@belongsTo(options)Legacy belongsToProperty
@hasMany(options)Legacy hasManyProperty
@readonlyServer-managed fieldProperty
@optionalOptional on createProperty
@createonlyOnly set on createProperty
@editonlyOnly set on editProperty

Common Options ​

typescript
interface BaseFieldOptions {
  sourceKey?: string;      // Alternative cache key
  type?: string;           // Transform/derivation name
}

interface SchemaOptions {
  polymorphic?: boolean;   // Allow multiple types
  typeField?: string;      // Discriminator field
  defaultValue?: boolean;  // Initialize with default
}

interface ArrayOptions {
  key?: '@identity' | '@index' | '@hash' | string;
  arrayExtensions?: string[];
}

interface ObjectOptions {
  objectExtensions?: string[];
}

interface ResourceOptions {
  legacy?: boolean;        // Enable legacy mode
  identityField?: string;  // Custom identity field name
}

interface ObjectSchemaOptions {
  hash?: boolean;          // Include hash field
  extensions?: string[];   // Object-level extensions
}

Compilation ​

The DSL compiles TypeScript schemas to JSON at build time.

Build Setup ​

typescript
// vite.config.ts
import { defineConfig } from 'vite';
import { schemaDSL } from '@warp-drive/schema-dsl/vite';

export default defineConfig({
  plugins: [
    schemaDSL({
      schemas: './app/schemas/**/*.ts',
      output: './app/schemas/generated.json'
    })
  ]
});

Generated Output ​

The compiler produces:

  1. JSON Schema File: All schemas as JSON for runtime registration
  2. Type Definitions: TypeScript types for read/create/edit operations
  3. Source Maps: For debugging
json
// schemas/generated.json
{
  "resources": {
    "user": {
      "type": "user",
      "identity": { "kind": "@id", "name": "@id" },
      "fields": [
        { "kind": "field", "name": "firstName" },
        { "kind": "field", "name": "lastName" }
      ]
    }
  },
  "objects": {
    "address": {
      "type": "address",
      "identity": null,
      "fields": [
        { "kind": "field", "name": "street" },
        { "kind": "field", "name": "city" }
      ]
    }
  },
  "traits": {
    "timestamped": {
      "name": "timestamped",
      "mode": "polaris",
      "fields": [
        { "kind": "field", "name": "createdAt", "type": "date-time" },
        { "kind": "field", "name": "updatedAt", "type": "date-time" }
      ]
    }
  }
}

Runtime Registration ​

typescript
import { schemas } from './schemas/generated.json';

// Register all schemas
store.schema.registerResources(schemas.resources);
Object.values(schemas.traits).forEach(trait =>
  store.schema.registerTrait(trait)
);

// Or register individually
import { User } from './schemas/resources/user';
store.schema.registerResource(User);

FAQ ​

Why use a DSL instead of JSON? ​

The DSL provides:

  • Type safety and IDE support
  • Better refactoring experience
  • Compile-time validation
  • Generated types for type-safe operations
  • While still producing flexible JSON output

Can I mix DSL and JSON schemas? ​

Yes! The DSL compiles to the same JSON format, so you can use both:

typescript
// DSL schema
@Resource
class User {
  @field declare name: string;
}

// JSON schema
const PostSchema = {
  type: 'post',
  fields: [
    { kind: 'field', name: 'title' }
  ]
};

// Both work together
store.schema.registerResource(User);
store.schema.registerResource(PostSchema);

How do I add custom field types? ​

Use the type option with transformations:

typescript
// Register transformation
schema.registerTransformation({
  type: 'color',
  serialize(value: { r: number, g: number, b: number }): string {
    return `rgb(${value.r},${value.g},${value.b})`;
  },
  hydrate(value: string): { r: number, g: number, b: number } {
    const [r, g, b] = value.match(/\d+/g).map(Number);
    return { r, g, b };
  }
});

// Use in schema
@Resource
class Theme {
  @field({ type: 'color' })
  declare primaryColor: { r: number, g: number, b: number };
}

How do I handle nullable fields? ​

Use TypeScript's union types:

typescript
@Resource
class User {
  @field declare name: string;

  @field declare middleName: string | null;

  @schemaObject({ type: 'address' })
  declare address: Address | null;
}

Can traits have traits? ​

Yes! Traits support composition:

typescript
@Trait('auditable')
@trait(Timestamped)
class Auditable {
  @field declare createdBy: string;
  @field declare updatedBy: string;

  // Also has createdAt, updatedAt from Timestamped
}

How do I version schemas? ​

Use different resource types:

typescript
// Version 1
@Resource('user:v1')
class UserV1 {
  @field declare name: string;
}

// Version 2
@Resource('user:v2')
class UserV2 {
  @field declare firstName: string;
  @field declare lastName: string;
}

Or use traits for shared fields:

typescript
@Trait('user-base')
class UserBase {
  @field declare email: string;
}

@Resource('user:v1')
@trait(UserBase)
class UserV1 {
  @field declare name: string;
}

@Resource('user:v2')
@trait(UserBase)
class UserV2 {
  @field declare firstName: string;
  @field declare lastName: string;
}

Released under the MIT License.