Localess

Schema as Code

Define content schemas in TypeScript with @localess/schema and infer content types from them without a codegen step.

Package: @localess/schema

Everything you can build in the Schema editor can also be written as TypeScript in your own repository. Schemas become reviewable code with a history, and a fresh environment can be provisioned from a git clone plus one push instead of being clicked together by hand.

The content types come for free. @localess/schema infers them from your definitions in the type system itself — no generation step, so a schema change surfaces as a type error in the same edit, not after you remember to re-run a command.

Choosing a direction

Schemas have one source of truth, and you pick which side holds it.

Source of truthToolTypes
CMS-firstThe Localess spaceSchema editor UIGenerated by localess type generate
Code-firstYour repository@localess/schemaInferred by InferContentData

CMS-first suits teams where editors and content modellers work in the UI. Code-first suits teams that want schema changes reviewed in pull requests and applied through CI. Running both directions against one space will fight over the same records — choose one.

Installation

npm install @localess/schema@latest -D

Zero external dependencies, so it adds nothing to your production bundle. It's a build-time and type-time tool.

Defining schemas

// schemas/button.ts
import { defineEnum, defineSchema } from '@localess/schema';

export const ButtonType = defineEnum({
  id: 'ButtonType',
  displayName: 'Button Type',
  values: [
    { name: 'Primary', value: 'primary' },
    { name: 'Secondary', value: 'secondary' },
  ],
});

export const Button = defineSchema({
  id: 'Button',
  type: 'NODE',
  displayName: 'Button',
  previewField: 'label',
  fields: [
    { name: 'label', kind: 'TEXT', required: true, translatable: true, maxLength: 50 },
    { name: 'type', kind: 'OPTION', source: ButtonType },
    { name: 'icon', kind: 'ASSET', fileTypes: ['IMAGE'] },
  ],
});
// schemas/page.ts
import { defineSchema } from '@localess/schema';

import { Button } from './button';

export const Page = defineSchema({
  id: 'Page',
  type: 'ROOT',
  fields: [
    { name: 'title', kind: 'TEXT', required: true },
    { name: 'blocks', kind: 'SCHEMAS', schemas: [Button] },
  ],
});

Notice that source and schemas take the definitions themselves, not string ids. A renamed or deleted schema becomes a compile error at the reference site rather than a mismatch discovered at push time. String ids are still accepted where a by-value reference isn't practical.

The config

One defineConfig() call collects every schema. This is the unit the CLI loads and the unit type inference resolves references against, so a schema missing from it is invisible to both.

// schemas/index.ts
import { defineConfig } from '@localess/schema';

import { Button, ButtonType } from './button';
import { Page } from './page';

export const config = defineConfig({ schemas: [Page, Button, ButtonType] });

defineConfig throws on a duplicate schema id, and defineSchema throws on a duplicate field name — those are programming mistakes rather than authoring problems, so they fail immediately instead of being collected as validation issues.

Inferring content types

import type { InferContent, InferContentData, InferEnum } from '@localess/schema';

import { config } from './schemas';
import { ButtonType, Page } from './schemas/page';

type AnyPage = InferContentData<typeof config>;
// { _id: string; _schema: 'Page'; title: string; blocks?: ButtonContent[] }

type PageContent = InferContent<typeof Page, typeof config>;
type ButtonVariant = InferEnum<typeof ButtonType>;
// 'primary' | 'secondary'
ExportGives you
InferContentData<C>A union of every ROOT schema's content type in the config
InferContent<S, C>The content type of one schema, resolved against the config
InferEnum<E>The literal union of an enum's values

Every inferred type carries _id: string and _schema as a literal. Fields marked required: true become non-optional; everything else is optional. OPTION resolves to the referenced enum's literal union rather than plain string, and SCHEMA/SCHEMAS resolve to the allowed schemas' content types.

Feed the result straight into a fetch to get an end-to-end typed response:

const content = await client.getContentBySlug<PageContent>('home');

Field kinds

The kind values match the field types in the editor one-for-one. Each kind accepts the base properties — displayName, required, description, defaultValue, translatable — plus its own:

KindExtra propertiesInferred type
TEXT, TEXTAREA, MARKDOWNminLength?, maxLength?string
RICH_TEXTminLength?, maxLength?ContentRichText
NUMBERminValue?, maxValue?number
COLOR, DATE, DATETIMEstring
BOOLEANboolean
OPTIONsource (required)The enum's literal union
OPTIONSsource (required), minValues?, maxValues?That union, as an array
LINKContentLink
REFERENCE / REFERENCESpath?ContentReference / ContentReference[]
ASSET / ASSETSfileTypes?, fileType?ContentAsset / ContentAsset[]
SCHEMA / SCHEMASschemas? (unrestricted when absent)The allowed schemas' type / an array of it

fileTypes accepts 'ANY', 'IMAGE', 'VIDEO', 'TEXT', 'AUDIO', and 'APPLICATION'.

Catching a stray property with defineField

A field written as a bare object literal can't be checked for properties belonging to a different kind — a TypeScript limitation on literals nested inside a const-inferred generic array. Wrapping a field in defineField() restores that check at the call site:

import { defineField } from '@localess/schema';

defineField({ name: 'amount', kind: 'NUMBER', maxLength: 5 });
// ^ compile error: maxLength is not valid on a NUMBER field

defineField({ name: 'amount', kind: 'NUMBER', minValue: 0 }); // OK

It's optional and purely additive: defineSchema accepts wrapped and bare fields interchangeably, defineField returns its argument unchanged at runtime, and inference is identical either way. A missing required property (omitting source on an OPTION) is caught with or without it.

Validating

validate(config) checks authoring rules and returns { ok, issues } rather than throwing, so you can report every problem at once:

import { validate } from '@localess/schema';

import { config } from './schemas';

const result = validate(config);
if (!result.ok) {
  for (const issue of result.issues) {
    console.error(`${issue.severity} ${issue.code} ${issue.path} — ${issue.message}`);
  }
}

Rules cover the constraints the editor enforces in the UI: schema ids are alphanumeric, 2–50 characters, and not one of the reserved names (Content, ContentData, Translations, …); field names are camelCase, 2–30 characters, and not _id/_schema; previewField names a field the schema actually has; and every source/schemas reference resolves to a schema of the right type.

In practice you'll run this through the CLI rather than by hand — localess schema validate does exactly this and needs no credentials, which makes it a good first step in any pipeline.

Syncing with a space

The localess schema commands connect your definitions to a space:

CommandDirection
schema validateNeither — offline check
schema pullSpace → code, to adopt schema-as-code on an existing space
schema diffNeither — reports drift, exits 1 on any
schema pushCode → space

Wire format

toSchemaExport(config) maps a config to SchemaExport[] — the exact shape the API accepts and returns. It's a pure function that performs no I/O, so it's also what you'd reach for to snapshot-test your schemas or diff two configs yourself. The CLI uses it internally.

On this page