Type Generation
Generate TypeScript types from your CMS schemas for type-safe content access across your entire app.
Aphex includes a CLI that reads your schema definitions and outputs a TypeScript file with interfaces for every document and object type, plus module augmentation that makes localAPI.collections fully type-safe.
You normally don't run this by hand. The aphex() Vite plugin regenerates generated-types.ts on schema save, you commit it, and builds/CI/prod use the committed file — so types just stay in sync. The CLI below is what the plugin calls under the hood; you'd invoke it directly only for edge cases (catching up after a pull without starting dev, or a CI drift-check).
Running the generator
pnpm aphex generate:types [schema-path] [output-path]If you omit the arguments, the CLI prompts you interactively. Most projects wire it up as an npm script:
{
"scripts": {
"generate:types": "aphex generate:types ./src/lib/schemaTypes/index.ts ./src/lib/generated-types.ts"
}
}Then run:
pnpm generate:typesDefault paths
| Argument | Default |
|---|---|
schema-path | ./src/lib/schemaTypes/index.ts |
output-path | ./src/lib/generated-types.ts |
What happens under the hood
- The CLI compiles your TypeScript schema file with esbuild (bundled as ESM).
- Icon imports from
@lucide/svelteare stubbed out — they aren't serializable and aren't needed for types. - The compiled module is dynamically imported and the
schemaTypesarray (ordefaultexport) is extracted. - Interfaces and module augmentation are generated.
- The output file is written and temp files are cleaned up.
Output structure
The generated file has four sections:
/**
* Generated types for Aphex CMS
* This file is auto-generated - DO NOT EDIT manually
*/
import type { CollectionAPI, SingletonCollection } from '@aphexcms/cms-core/server';
/**
* A reference to another document, stored as { _type: 'reference', _ref }.
* At depth=0 (default) this is the raw shape; at depth=1 the field is
* replaced with the target document — see the *Resolved variants.
*/
export interface Reference<T = unknown> {
_type: 'reference';
_ref: string;
_key?: string;
__targetType?: T;
}
// ============================================================================
// Object Types (nested in documents)
// ============================================================================
export interface TextBlock {
_type?: string;
heading?: string;
content: string;
}
// ============================================================================
// Document Types (collections)
// ============================================================================
export interface Menu {
id: string;
title: string;
items: Reference<MenuItem>[];
_meta?: {
/* ... */
};
}
export interface MenuItem {
id: string;
name: string;
price: number;
_meta?: {
/* ... */
};
}
// ============================================================================
// Resolved Types (depth=1) — refs swapped for their target docs
// ============================================================================
export interface MenuResolved {
id: string;
title: string;
items: MenuItem[];
_meta?: {
/* ... */
};
}
// ============================================================================
// Module Augmentation
// ============================================================================
declare module '@aphexcms/cms-core/server' {
interface Collections {
menu: CollectionAPI<Menu>;
menuItem: CollectionAPI<MenuItem>;
}
}Object types
For each object schema, the generator creates an interface with a _type discriminator field. This is used when objects appear in arrays to identify which type each item is.
Document types
For each document schema, the generator creates an interface that includes:
id: string— the document ID.- Your schema fields, with correct types and optionality.
_meta?— document metadata (status, timestamps, organization, etc.).
Module augmentation
The generated declare module block extends the Collections interface from @aphexcms/cms-core/server. This means localAPI.collections.page is typed as CollectionAPI<Page> — giving you autocompletion and type checking on all CRUD operations.
Type mapping
| Schema field type | TypeScript type (raw) | Resolved (depth=1) | Notes |
|---|---|---|---|
string | string | string | |
text | string | string | |
slug | string | string | |
url | string | string | |
number | number | number | |
boolean | boolean | boolean | |
date | string | string | ISO date string (YYYY-MM-DD). |
datetime | string | string | ISO datetime string (YYYY-MM-DDTHH:mm:ssZ). |
image | string | string | Asset ID reference. |
reference | Reference<Target> | Target | Target doc interface. |
array (of references) | Reference<Target>[] | Target[] | Array of document references. |
array (single type) | Type[] | Type[] | e.g. TextBlock[]. |
array (multiple types) | Array<A | B> | Array<A | B> | Union of all item types. |
object (inline fields) | Inline { ... } | Inline { ... } | Generated as an anonymous object type. |
object (named schema) | SchemaName | SchemaName | References the generated interface. |
Optionality
Fields are marked optional (?) based on your validation rules. If a field has validation: (Rule) => Rule.required(), it's required in the generated type. Otherwise, it's optional.
Using generated types
Import in your code
import type { Page, TextBlock } from '$lib/generated-types';Type-safe Local API
With module augmentation active, the Local API is fully typed:
import { json } from '@sveltejs/kit';
import { authToContext } from '@aphexcms/cms-core/server';
export const GET = async ({ locals }) => {
const api = locals.aphexCMS.localAPI;
const context = authToContext(locals.auth);
// api.collections.page is typed as CollectionAPI<Page>
const result = await api.collections.page.find(context, {
where: { title: { contains: 'hello' } },
perspective: 'published'
});
// result.docs is Page[]
return json({ data: result.docs });
};Type-safe content rendering
<script lang="ts">
import type { Page } from '$lib/generated-types';
let { data } = $props();
const page: Page = data.page;
</script>
<h1>{page.title}</h1>Resolved types (depth=1)
For each schema that contains reference fields, the generator also emits a *Resolved interface where refs are replaced with the target's raw interface. Use these when reading with depth: 1:
import type { MenuResolved } from '$lib/generated-types';
import { authToContext } from '@aphexcms/cms-core/server';
export const GET = async ({ params, locals }) => {
const api = locals.aphexCMS.localAPI;
const context = authToContext(locals.auth);
// depth: 1 resolves the refs into full docs
const menu = (await api.collections.menu.get(context, {
id: params.id,
depth: 1
})) as unknown as MenuResolved;
// menu.items is MenuItem[] — not Reference<MenuItem>[]
return json({ data: menu });
};At depth=1, only the outer document's references are resolved. Refs inside the resolved targets
stay raw. That's why MenuResolved.items is MenuItem[] (not MenuItemResolved[]).
Block content types
When a schema has a block array field (an array whose of contains {type: 'block'}), the generator emits strongly-typed interfaces for every custom block type, inline object, and annotation defined in that field. This lets you type your @portabletext/svelte component props precisely instead of using any.
What gets generated
Given this schema:
{
name: 'content',
type: 'array',
of: [
{
type: 'block',
marks: {
annotations: [
{ name: 'footnote', fields: [{ name: 'text', type: 'text' }] }
]
},
of: [
{ type: 'inlineNote', fields: [{ name: 'text', type: 'text' }] }
]
},
{ type: 'image' },
{ type: 'callout', fields: [{ name: 'tone', type: 'string' }, { name: 'text', type: 'text' }] }
]
}The generator emits:
// Standalone interfaces with _type literal discriminators
export interface PortableTextImageBlock { _type: 'image'; asset?: { _ref: string; _type: 'reference' } }
export interface CalloutBlock { _type: 'callout'; tone?: string; text?: string }
export interface InlineNoteInline { _type: 'inlineNote'; text?: string }
export interface FootnoteAnnotation { _type: 'footnote'; text?: string }
// Per-field content map — discriminated union of all custom types for this field
export interface MyDocumentContentTypes {
image: PortableTextImageBlock;
callout: CalloutBlock;
inlineNote: InlineNoteInline;
footnote: FootnoteAnnotation;
}
// The field itself types to a union array
// content: Array<PortableTextBlock | PortableTextImageBlock | CalloutBlock>Using the types in components
Pass the generated interface as the generic to CustomBlockComponentProps or MarkComponentProps:
<!-- Callout.svelte -->
<script lang="ts">
import type { CustomBlockComponentProps } from '@portabletext/svelte';
import type { CalloutBlock } from '$lib/generated-types';
interface Props {
portableText: CustomBlockComponentProps<CalloutBlock>;
}
let { portableText }: Props = $props();
</script>
<div class="callout callout-{portableText.value.tone}">
{portableText.value.text}
</div><!-- FootnoteMark.svelte -->
<script lang="ts">
import type { MarkComponentProps } from '@portabletext/svelte';
import type { FootnoteAnnotation } from '$lib/generated-types';
interface Props {
portableText: MarkComponentProps<FootnoteAnnotation>;
children: Snippet;
}
let { portableText, children }: Props = $props();
</script>
<span title={portableText.value.text}>{@render children()}</span>TypeScript will now error if you access a field that doesn't exist on the block type, and autocomplete will suggest the correct fields.
Content map type
The *ContentTypes mapped type is useful when you want to look up a block interface by its _type string:
import type { MyDocumentContentTypes } from '$lib/generated-types';
type CalloutData = MyDocumentContentTypes['callout'];
// → CalloutBlockWhen does it regenerate
Automatically, on save, whenever you change a schema while pnpm dev runs:
- Add a new document or object schema.
- Add, remove, or rename fields in a schema.
- Change validation rules (affects optionality).
- Rename a schema.
The output is committed to source control, so CI, builds, prod, and teammates all use the committed file — none of them regenerate it. You'd only invoke generate:types by hand to catch up if a schema changed while the dev server was off, or as an optional CI drift-check (regenerate and fail if it produces a git diff, proving the committed types match the schema).
Last updated on