Aphex

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:

package.json
{
	"scripts": {
		"generate:types": "aphex generate:types ./src/lib/schemaTypes/index.ts ./src/lib/generated-types.ts"
	}
}

Then run:

pnpm generate:types

Default paths

ArgumentDefault
schema-path./src/lib/schemaTypes/index.ts
output-path./src/lib/generated-types.ts

What happens under the hood

  1. The CLI compiles your TypeScript schema file with esbuild (bundled as ESM).
  2. Icon imports from @lucide/svelte are stubbed out — they aren't serializable and aren't needed for types.
  3. The compiled module is dynamically imported and the schemaTypes array (or default export) is extracted.
  4. Interfaces and module augmentation are generated.
  5. The output file is written and temp files are cleaned up.

Output structure

The generated file has four sections:

src/lib/generated-types.ts
/**
 * 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 typeTypeScript type (raw)Resolved (depth=1)Notes
stringstringstring
textstringstring
slugstringstring
urlstringstring
numbernumbernumber
booleanbooleanboolean
datestringstringISO date string (YYYY-MM-DD).
datetimestringstringISO datetime string (YYYY-MM-DDTHH:mm:ssZ).
imagestringstringAsset ID reference.
referenceReference<Target>TargetTarget 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)SchemaNameSchemaNameReferences 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:

src/routes/api/pages/+server.ts
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

src/routes/blog/[slug]/+page.svelte
<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:

src/routes/api/menus/[id]/+server.ts
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'];
// → CalloutBlock

When 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).

Edit on GitHub

Last updated on