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 truth | Tool | Types | |
|---|---|---|---|
| CMS-first | The Localess space | Schema editor UI | Generated by localess type generate |
| Code-first | Your repository | @localess/schema | Inferred 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 -DZero 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'| Export | Gives 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:
| Kind | Extra properties | Inferred type |
|---|---|---|
TEXT, TEXTAREA, MARKDOWN | minLength?, maxLength? | string |
RICH_TEXT | minLength?, maxLength? | ContentRichText |
NUMBER | minValue?, maxValue? | number |
COLOR, DATE, DATETIME | — | string |
BOOLEAN | — | boolean |
OPTION | source (required) | The enum's literal union |
OPTIONS | source (required), minValues?, maxValues? | That union, as an array |
LINK | — | ContentLink |
REFERENCE / REFERENCES | path? | ContentReference / ContentReference[] |
ASSET / ASSETS | fileTypes?, fileType? | ContentAsset / ContentAsset[] |
SCHEMA / SCHEMAS | schemas? (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 }); // OKIt'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:
| Command | Direction |
|---|---|
schema validate | Neither — offline check |
schema pull | Space → code, to adopt schema-as-code on an existing space |
schema diff | Neither — reports drift, exits 1 on any |
schema push | Code → 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.