Guide

TypeScript guide.

Write Aktion modules in TypeScript (.aktion.ts) and have tsc check every component call against the library — and type everything around the program, from the host element to interceptors and event payloads, with no any leaks.

Two halves. Aktion modules in TypeScript covers the program itself: .aktion.ts and .aktion.js files, typed by aktion-runtime/dsl. Everything from Install & tsconfig on covers the integration surface — the TypeScript you write around <aktion-app>. A plain .aktion file stays untyped DSL text; see Language.

Aktion modules in TypeScript

An Aktion module can be written in three languages. They compile to the same program, import each other freely, and share $ state:

FileLanguageCompiled by
app.aktionThe Aktion DSLthe Aktion parser
app.aktion.jsJavaScript that keeps to the Aktion subsetthe JavaScript frontend, then the parser
app.aktion.tsThe same, with TypeScript typests-blank-space erases the types, then the JavaScript frontend
utils.ts / utils.jsNative codeVite — host code only; an Aktion module cannot import it (yet)

The .aktion in the name is what makes a file an Aktion module: store.aktion.ts is one, store.ts is host code. Type erasure blanks every annotation with spaces, so the module keeps its length and its line breaks and every error, coverage hit and DevTools position is on the line and column you wrote — no source map involved.

Start a project with --lang ts (or --lang js):

npx create-aktion my-app --template todos-app --lang ts

or add the optional peer to an existing one — the Vite plugin picks it up on its own:

npm install -D ts-blank-space

Store, component, entry

// src/types.ts — native TypeScript, types only
export interface Todo { id: number; title: string; isCompleted: boolean }
// src/store.aktion.ts — an Aktion module in TypeScript
import { $http } from "aktion-runtime/dsl";
import type { Todo } from "./types.ts";
import { base } from "./api.aktion.js";   // a JavaScript Aktion module — for api.aktion.ts write "./api.aktion.ts"

export const $todos = $http<Todo[]>({ url: `${base}/todos` });
export let $editingId: number | null = null;   // reassigned → `let`, never `const`

export function startEdit(todo: Todo): void { $editingId = todo.id; }
export function stopEdit(): void { $editingId = null; }

export function toggle(todo: Todo): void {
  const req = $http({ url: `${base}/todos/${todo.id}`, method: "PATCH", body: { isCompleted: !todo.isCompleted } });
  req.onDone = () => $todos.refetch();
}
// src/components/todo-row.aktion.ts
import { Row, Text, Checkbox, Button, type AktionNode } from "aktion-runtime/dsl";
import { $editingId, toggle, startEdit } from "../store.aktion.ts";
import type { Todo } from "../types.ts";

export function TodoRow(todo: Todo): AktionNode {
  return Row([
    Checkbox(`done-${todo.id}`, { label: todo.title, labelHidden: true, value: todo.isCompleted, onChange: () => toggle(todo) }),
    Text(todo.title, { tone: $editingId === todo.id ? "primary" : "default" }),
    Button("Edit", { onClick: () => startEdit(todo), variant: "ghost" }),
  ], { gap: "s", align: "center" });
}
// src/app.aktion.ts — the entry
import { $app, Column, PageHeader, Async, LoadingState, EmptyState } from "aktion-runtime/dsl";
import { $todos } from "./store.aktion.ts";
import { TodoRow } from "./components/todo-row.aktion.ts";
import { Composer } from "./components/composer.aktion";   // a plain .aktion module

function App() {
  const rows = ($todos.data ?? []).map((t) => TodoRow(t));
  return Column([
    PageHeader("Todos"),
    Composer(),
    Async($todos, { loading: LoadingState("Loading…"), empty: EmptyState("Nothing yet"), data: Column(rows) }),
  ], { gap: "l" });
}

export default $app(App());   // typed as the CompiledProgram the host mounts
// src/main.ts — host code
import "aktion-runtime";
import type { AktionElement } from "aktion-runtime";
import app from "./app.aktion.ts";
document.querySelector<AktionElement>("#app")?.mountCompiled(app);

Host code imports only an Aktion module’s default export, its compiled program. The other exports exist inside Aktion programs: a named import from host code (import { summary } from "./api.aktion.ts" in main.ts) fails vite build — with an explanation on Vite 5–7, while Vite 8 reports the missing export — and under vite dev and Vitest the binding throws as soon as it is used. To unit-test a helper, call it from a program (compileAktionSource, see Testing).

Keys and children

A TypeScript component’s signature is its parameter list, so a call that passes a key or children needs a parameter for them: Item(id, { key: id }) against function Item(label: string) is TS2554, and so is Panel("P", Text("child")) against function Panel(title: string). Two conventions type-check and bind as written:

import { Column, Row, Text, type AktionNode, type Children, type ComponentOptions } from "aktion-runtime/dsl";

// A trailing optional parameter takes the key (`ComponentOptions` is `{ readonly key?: Key }`).
export function OrderRow(name: string, total: number, _opts?: ComponentOptions): AktionNode {
  return Row([Text(name), Text(String(total))], { gap: "s" });
}

// A declared parameter takes the children.
export function Shell(title: string, children: Children): AktionNode {
  return Column([Text(title), children], { gap: "m" });
}

Shell("Orders", orders.map((o) => OrderRow(o.name, o.total, { key: o.id })));

The call binds by position, as JavaScript does, so _opts receives the { key } object. Because that object is the last argument and key is its only property, Aktion also takes it as the row’s identity, so per-instance state stays with its row when the list is re-sorted. For children, a rest parameter works too: function Panel(title: string, ...kids: AktionNode[]) receives every remaining argument as an array.

The built-ins: aktion-runtime/dsl

aktion-runtime/dsl is a types-only module that declares every built-in: the 282 components (one overload per way a call can bind its arguments), $state, $memo, $http, $store, $form, $effect, $router, $util and the rest, plus AktionNode and CompiledProgram. The linker drops the import, so the names resolve exactly as they would without it. Import built-ins by their own name — an alias ({ Button as B }) is an error (E122).

The declarations are generated from the runtime itself — the component specs, the arguments each renderer hands its callbacks, and the tables sx, $theme and the namespaces read — so they are exact. A value tsc rejects is one the runtime would drop, ignore or render wrongly, usually without a word; where the runtime takes an open set (a CSS length, a colour, an icon name), the type is open too. When tsc flags a call, trust the error over how the call looks:

Button("Save", { variant: "brand" });                  // TS2769: not a ButtonVariant
Button("Save", "primary");                             // TS2769: the second slot is onClick, not variant
Input("age", { onChange: (value: number) => value });  // TS2769: onChange receives the string value
Text("x", { sx: { _hover: { bg: "primary" } } });      // TS2769: no _hover key — use hover, focus or states
$theme({ colors: { "bg-subtle": "#ffffff" } });        // TS2353: $theme colour keys are camelCase (bgSubtle)
SurfaceHow it is typed
PropsEach prop has its exact type. An enum prop is a union of its literal values, exported as <Component><Prop> (ButtonVariant, BadgeTone, ModalSize) — the spacing props of the layout components (gap, padding, …) share SpacingToken; the data a component reads has a shape type (SelectItemData, CalendarViewEvent, CommandPaletteItem); <Component>Options is the whole props bag. Import them with import type.
CallbacksExactly the arguments the renderer passes: Input’s onChange gets the string value, Col’s render gets (value, index, row), Button’s onClick gets nothing. A callback whose result the runtime ignores returns unknown; every callback prop accepts null.
Components that hand your data backGeneric, inferred from the data you pass: VirtualList, VirtualGrid, Col, Draggable, Mount, Calendar, CalendarView, ActivityLog, Gallery, FieldRepeater, NotificationBell, PresenceAvatars, SegmentedControl, ToggleGroup.
DOM valuesDomElement, DomFile, DomEvent, DomKeyboardEvent, DomMouseEvent, DomPointerEvent, DomFocusEvent, DomDragEvent, DomWheelEvent, DomClipboardEvent, DomInputEvent.
sx, animate, roleSxProps, AnimateValue and AriaRole, printed from the runtime’s own tables. class / className take a string or an array; style takes a string.
$theme, iconsThemeConfig, with one key set per token group; AktionIconName for icon names.
$util, $toast, $headGeneric helpers (the element type flows through); the value sets each one honours.
Data, stores, forms, routesA type argument for $http<T> / $query<T>; Store<Fields> for a $store method’s handle; one Validator<T> per $form field; RouteParamsOf<"/users/:id"> for route params.

Props and callbacks. A callback parameter is typed from the component, so you rarely annotate one. The runtime ignores what an event handler returns, so handlers return unknown and may return anything, a promise included; only the few callbacks whose result is used are typed with what they must return (VirtualList’s renderItem and Col’s render return what to render, Mount’s setup the instance). Every callback prop also accepts null, which leaves the component without that handler — switch one on and off with a ternary. (The snippets in this section share Order, rows and the atoms declared in them.)

import { $util, Button, Column, Input, type AktionNode, type BadgeTone } from "aktion-runtime/dsl";

interface Order { id: number; customer: string; paid: boolean; plan: "free" | "pro" }

let $email = "";
let $dirty = false;
function save(): void { $dirty = false; }

export function statusTone(order: Order): BadgeTone {   // "neutral" | "primary" | "success" | …
  return order.paid ? "success" : "warning";
}

export function EmailForm(): AktionNode {
  return Column([
    Input("email", { value: $email, onChange: (value) => { $email = value; $dirty = true; } }),   // value: string
    Button("Save", { onClick: $dirty ? save : null, variant: "primary" }),                       // null: no handler
    Button("Copy", { onClick: () => $util.copy($email).then((ok) => { $dirty = !ok; }) }),      // a promise is fine
  ]);
}

Components that hand your data back are generic. A list, a column, a drag payload or a mounted widget infers its type parameter from what you pass, and every callback that gives the value back receives that type:

import { $query, Badge, Col, DataGrid, Text, VirtualList } from "aktion-runtime/dsl";

const $orders = $query<Order[]>({ url: "/api/orders" });   // .data: Order[] | undefined
const rows: Order[] = $orders.data ?? [];

export const list = VirtualList(rows, {
  renderItem: (order) => Text(order.customer),       // order: Order
});

export const grid = DataGrid([
  Col("Customer", rows.map((o) => o.customer)),
  Col("Plan", rows.map((o) => o.plan), {
    render: (plan) => Badge(plan, { tone: plan === "pro" ? "primary" : "neutral" }),   // plan: "free" | "pro"
  }),
]);

DOM values arrive as the Dom* bridge types. A Mount host, a keyboard event or a picked file is typed as DomElement, DomKeyboardEvent, DomFile and so on. With the DOM lib loaded these are lib.dom’s own HTMLElement, KeyboardEvent and File; without it, they are the structural subset the runtime guarantees, so the same module type-checks either way:

import { FileUpload, Mount, OnKeyboard, Text } from "aktion-runtime/dsl";

export const widget = Mount((node) => {               // node: DomElement
  node.setAttribute("data-ready", "true");
  return { zoom: 3 };                                   // the instance
}, {
  update: (instance, _props, node) => node.setAttribute("data-zoom", String(instance.zoom)),   // instance: { zoom: number }
});

export const keys = OnKeyboard(Text("Escape discards the draft"), (event) => {   // event: DomKeyboardEvent
  if (event.key === "Escape") $dirty = false;
});

export const avatar = FileUpload("avatar", {
  onSelect: (files) => { $email = files.map((f) => f.name).join(", "); },   // files: DomFile[]
});

sx is a typed style object, derived from the tables the runtime resolves it with (see The sx prop). Its keys are closed, so a key the runtime would ignore is an error — there is no _hover; use hover, focus or states. Enum keys (display, position, shadow, cursor…) take only their values. Token keys autocomplete their tokens and still accept any CSS string, through (string & {}): "60ch", "#0969da", "clamp(1rem, 2vw, 2rem)". A number on a length key is pixels, and the keys that honour a breakpoint map accept one. hover and focus take an effect name or an object of effect flags and state styles (focus has only the glow and border effects), and states one style object per state. animate takes an AnimatePreset name, "none", or { preset, duration, delay, repeat }; role takes the allow-listed AriaRoles; and style is a string — build one from an object with $util.style.toStyle({ … }).

import { $util, Column, Text, type SxProps } from "aktion-runtime/dsl";

export const panel: SxProps = {
  p: 16,                                      // a number on a length key is px
  maxW: "60ch",                               // tokens autocomplete; any CSS length type-checks
  bg: { base: "surface", md: "bg-subtle" },   // a breakpoint map
  radius: "lg",
  hover: "lift",
  states: { active: { scale: 0.98 }, "focus-visible": { borderColor: "primary" } },
};

export const card = Column([Text("Hello")], {
  sx: panel,
  animate: { preset: "fade-up", duration: 300 },
  role: "region",
  className: ["card", $dirty ? "card--dirty" : ""],
  style: $util.style.toStyle({ outlineOffset: "2px" }),
});

$theme and icons. ThemeConfig has one key set per token group — colors takes the camelCase colour tokens (bgSubtle, primaryText), radius, spacing, shadows and zIndex their own scales, gradients the six gradient tokens (two or more colour stops, { stops, angle }, or a linear-gradient(…)-style string) — so an unknown token, or a plain colour where a gradient belongs, is an error rather than a silently dropped value. AktionIconName types an icon name in your own components: a Font Awesome name, a "regular:star"-style prefixed name, or one you registered. It stays open to any string, as the built-in icon props are, and the names you add to AktionIconRegistry autocomplete.

The built-in functions. The $util list helpers are generic: $util.first(rows) is Order | null, a field argument autocompletes the element’s keys, and $util.worker(fn, …args) checks the arguments against fn. $toast takes only the toast tones and corner positions (ToastShowTone, ToastStackPosition) and $head only the link rels it renders (HeadLinkRel — no stylesheet). Give $http and $query the response type, as above. A $store method’s handle is any unless you annotate it, because TypeScript cannot infer it from the object literal it is defined in; annotate it with Store<Fields>, and callers of the store are fully typed. $form hands each field’s validators that field’s value:

import { $form, $store, $util, Button, type Store } from "aktion-runtime/dsl";

export const latest = $util.first(rows);                 // Order | null
export const byPlan = $util.groupBy(rows, "plan");       // Record<string, Order[]>

interface Cart { items: string[] }
export const cart = $store({
  items: [] as string[],
  add: (s: Store<Cart>, item: string) => { s.items = [...s.items, item]; },
});
export const addApple = Button("Add", { onClick: () => cart.add("apple") });   // callers pass only `item`

export const signup = $form({
  values: { email: "", age: 0 },
  rules: {
    email: [$util.rules.required(), $util.rules.email()],
    age: $util.rules.custom((age) => age >= 18 || "Adults only"),   // age: number
  },
});

Route params have a type per pattern: RouteParamsOf<"/users/:id/files/*"> is { readonly id: string; readonly _: string } (a trailing * captures the rest of the path as _). Arms are expressions, so params cannot be typed per arm by inference — cast it in the arm: UserPage(params as RouteParamsOf<"/users/:id">).

Prefer the module form. For code that names built-ins without importing them, aktion-runtime/dsl-globals declares the same names ambiently — but only in a tsconfig without the DOM lib, because Text, Image and console collide with it. Host-registered components, and the icon names you register, join through module augmentation, in a file that is itself a module:

// src/aktion-env.d.ts
import "aktion-runtime/dsl";   // makes this file a module: without it, the block below replaces the built-ins

declare module "aktion-runtime/dsl" {
  interface AktionIconRegistry { logo: true }   // names from $theme({ icons }) or registerIcons
  export function PriceBadge(amount: number, props?: { currency?: string }): AktionNode<"PriceBadge">;
}

The tsconfig

{
  "compilerOptions": {
    "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "strict": true, "noEmit": true, "skipLibCheck": true,
    "allowImportingTsExtensions": true,   // `./store.aktion.ts` specifiers
    "verbatimModuleSyntax": true,         // forces `import type` for types
    "erasableSyntaxOnly": true,           // TypeScript ≥ 5.8: enums & co. flagged in the editor
    "allowArbitraryExtensions": true,     // `x.d.aktion.ts` declarations type `.aktion` imports
    "allowJs": true,                      // `.aktion.js` modules contribute types
    "rootDirs": ["src", ".aktion-types/src"],  // where the plugin's `dts` option writes them
    "types": ["vite/client"]
  },
  "include": ["src", "tests", ".aktion-types"]
}

A few TypeScript habits matter here. With these options tsc flags most slips; where it accepts the code, Aktion reports it:

RuleWhy
Declare atoms that change with letconst $x cannot be reassigned (TS2588).
Change an imported atom through an exported actionAn import is a read-only binding in TypeScript (TS2632).
Import types with import typeErasure removes type-only imports; a value import of a type would reach the linker, which reports does not export 'Todo'. The inline form is not enough: import { type Todo } from "./types.ts" still imports the native file for its side effects under verbatimModuleSyntax, and tsc accepts it, so Aktion refuses it and suggests import type { Todo }.
No enum, namespace, parameter properties, <T>x casts or decoratorsThey have runtime meaning and cannot be erased; Aktion reports each one at its position.
Write the .ts extensionAktion resolves files, not TypeScript module names: ./x.aktion.js for x.aktion.ts, which tsc resolves, reports Did you mean "./x.aktion.ts"?.

Handlers may return a promise. Every component callback whose result the runtime ignores is typed to return unknown, not void, so onClick: () => $util.copy(text).then(…) type-checks, and typescript-eslint’s no-misused-promises — which reports a promise-returning function only where a void return is expected — stays quiet about it.

Where Aktion is not JavaScript

Type-checking proves your code is valid TypeScript, not that it means what TypeScript thinks: Aktion interprets it under its own rules. For .aktion.ts and .aktion.js modules the compiler closes that gap two ways. Some differences it rewrites away, keeping every position:

The rest it rejects, with an error at the exact line and a stable code (the CHANGELOG lists every code; each error message says what to write instead):

CodeRejectedInstead
E101, E102await, async — bodies run synchronously.then(…), or $http(…) and its .onDone
E103, E104this, super, debugger, arguments, varparameters, a rest parameter, let / const
E105, E106a local reassigned after a closure captured it; a closure using a local declared after it — closures copy their scope when they are createda $state atom, $ref(…), or an object box; declare first
E107changing a module-level binding in place after it was built, in a function or at module top level (xs.push(2), byId[k] = v in a top-level loop, Object.assign(o, …)) — every render rebuilds it from its initializer, so the change is lost. Not reported for a $store / $form / $query handle or a host object read through a global (const root = document.documentElement), which survive the rebuildbuild the whole value in the initializer ([1, 2], Object.fromEntries(…), new Map([[k, v]])), or use a state atom (let $items = [])
E108$todos.push(t), Object.assign($cfg, …) and other in-place changes of a data atom — only assignment re-renders$todos = [...$todos, t]
E109, E110per-instance state or hooks anywhere but the top of a component (or hook) bodymove them up
E111a capitalised function used as a value — it is a componentname the helper in camelCase
E112, E115names the runtime owns (route, params, a binding named like a built-in component, __a1_x / __l1_x)rename
E113, E117–E121block statements ({ const x = 1 }, and the case X: { … } form ESLint’s no-case-declarations asks for), reported once at the {; spreads in a built-in’s props, $app anywhere but the top level, an $effect callback that is not inline, deps that are not an array literal, returning a cleanup from an effectsee each message
E114, E124–E126a derived atom that is also assigned, destructuring a $store / $form handle, assigning an undeclared name, components or hooks declared inside functionssee each message
E122, E123an aliased aktion-runtime/dsl import ({ Button as B }); a name imported from aktion-runtime/dsl and also declared in the moduleimport each built-in by its own name; remove one of the two
E127$router(…) arms Aktion cannot read from the source: arms passed as a value ($router(routes)), a spread (...more), a computed path ([path]: …), or a layout arm whose routes is not an object literal — the runtime ignores them: a table passed as a value renders nothing, a spread or computed arm is skipped, and the layout arm renders no child route. RouteTable accepts any object, so tsc does not catch this; in the editor, the ESLint rule aktion/router-literal flags the tables, spreads and routes that are not literalslist every arm, with a string path, in the object literal at the call: $router({ "/": Home(), default: NotFound() })
W201, W202Warnings: a module-level value from Date.now(), Math.random() or crypto (recomputed each render); a state-writing function called while renderingcompute it in a component, effect or action

A ${…} interpolation that is not one complete expression (${}, ${import.meta.env.X}, a BigInt, a typo) is a parse error at its own position, in every module language, .aktion included — it no longer renders as an empty string.

And some differences no compiler can see, because they depend on runtime values:

Importing .aktion modules from TypeScript

TypeScript does not read .aktion files, so import { TodoRow } from "./todo-row.aktion" in a .aktion.ts module needs a declaration. The plugin writes one per module — todo-row.d.aktion.ts, found through allowArbitraryExtensions — declaring each export:

// vite.config.ts
export default defineConfig({ plugins: [aktion({ dts: true })] });   // → .aktion-types/

For CI, run the same generator before tsc; --check fails when a declaration is stale. The --lang ts templates’ typecheck script does exactly this: aktion-dts --quiet && tsc.

npx aktion-dts && npx tsc --noEmit
npx aktion-dts --check

Modules of an alias — the plugin option, or aktion.config.json, which aktion-dts reads too (--no-config skips it) — are declared under .aktion-types/<prefix>/. TypeScript needs a paths entry for an alias anyway; give it the mirror as a fallback:

"paths": { "@acme/ui/*": ["../../libs/ui/src/*", "./.aktion-types/@acme/ui/*"] }

A directory reached through roots is not mirrored: run aktion-dts --out-dir . in that package, so each declaration sits next to its module. The declarations type imports between Aktion modules only — host code still takes just the default export (see above).

.aktion.js modules need no declarations — with allowJs, TypeScript reads them directly. Don’t keep a store.aktion next to a store.aktion.ts: TypeScript and Vite would resolve ./store.aktion to different files, so Aktion refuses the pair.

Linting

aktion-runtime/eslint exports aktionTypeScriptConfig for **/*.aktion.ts and **/*.aktion.js. It turns off the rules whose autofixes break Aktion code and the ones that report Aktion’s own idioms (component calls, top-level $app and $effect, actions that assign atoms). It adds two rules of its own: the type-aware aktion/props-literal and aktion/router-literal. Spread it after your own config:

// eslint.config.js
import tseslint from "typescript-eslint";
import unicorn from "eslint-plugin-unicorn";
import { aktionTypeScriptConfig } from "aktion-runtime/eslint";

export default [
  ...tseslint.configs.recommended,
  unicorn.configs.recommended,
  {
    languageOptions: {
      parserOptions: {
        projectService: { allowDefaultProject: ["*.config.js", "*.config.ts"] },
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
  ...aktionTypeScriptConfig,
];

allowDefaultProject is there for the files at the project root: eslint.config.js, vite.config.ts and vitest.config.ts are outside the tsconfig’s include, and without it the project service refuses to parse them. Because the preset comes last, it also wins over your own settings for these files, and two of its entries switch a rule on as an error: object-shorthand in properties mode ({ title: title } becomes { title }) and unicorn/switch-case-braces in avoid mode, which removes the braces Aktion cannot parse around a case body. The second needs eslint-plugin-unicorn; without it, add a block after the preset that turns unicorn/switch-case-braces off.

Install & tsconfig

Install the runtime and the declarations come with it — there is no separate @types package to add, and no build step to run:

npm install aktion-runtime
# types ship in the package; there is no @types/aktion-runtime

That one dependency carries the custom element, the 282-component library, the SSR helpers, and every type on this page. What it will not do is resolve under a legacy module-resolution mode, because the package publishes its subpaths through an exports map:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",           // or "NodeNext", with "module": "NodeNext"
    "lib": ["ES2022", "DOM", "DOM.Iterable"], // DOM is required — see the table
    "strict": true
  }
}

Those are the settings this package’s own build uses, and they are the smallest set that makes every entry point type-check. Only three of them actually matter:

SettingValueWhy
moduleResolution"bundler", "node16", or "nodenext"Required for the subpaths. Under the legacy "node" (node10) algorithm TypeScript ignores exports entirely: aktion-runtime still resolves through the package’s types field, but /test, /devtools, /language, /vite, and /aktion-modules all fail.
module"esnext" / "preserve" with bundler; "node16" / "nodenext" with those resolversTypeScript enforces the pairing itself: bundler with module: "commonjs" is rejected as TS5095 before it ever looks at an import.
libmust include "DOM"The declarations reference HTMLElement and Node — AktionElement extends HTMLElement, and a component’s render returns a Node. Without DOM you get TS2304 from inside dist/types. skipLibCheck: true hides those errors without fixing your own call sites.
stricttrueOptional, but free: the shipped declarations are strict-clean, so nothing on this page needs a loosened flag.

The error you get from the wrong resolver

TypeScript names the fix for you. With "moduleResolution": "node", importing aktion-runtime/test fails with TS2307 Cannot find module 'aktion-runtime/test' or its corresponding type declarations, followed by “There are types at …dist/types/testing/index.d.ts, but this result could not be resolved under your current 'moduleResolution' setting.” If you see that, change the resolver — do not add a path mapping.

Typed .aktion imports

The Vite plugin lets you import a .aktion file directly. Add one reference line to any .d.ts and those default imports stop being any (named imports need the declarations described in Importing .aktion modules from TypeScript):

/// <reference types="aktion-runtime/aktion-modules" />

With that in place, import app from "./app.aktion" is typed as the CompiledProgram the plugin emits — exactly what element.mountCompiled(app) expects. The /aktion-modules entry is declarations only; there is nothing to import from it at runtime.

Package entry points

The package exposes typed subpath entries. Import from the one that matches your task — splitting them is what keeps a CI lint step from pulling in the DOM:

ImportProvides
aktion-runtimeThe runtime: registers <aktion-app> on import, plus registerComponents, the themes, the SSR helpers, and every core type.
aktion-runtime/testThe Testing-Library-style API (render, renderComponent, within, axe, fetch mocks). See Testing.
aktion-runtime/devtoolsThe DevTools backend and in-page panel. See DevTools.
aktion-runtime/coverageThe DOM‑free coverage API — the lines, functions and branches of Aktion source the interpreter reached — sharing one session with aktion-runtime/test, for a Vitest globalSetup or a Node script. See Testing.
aktion-runtime/languageDOM‑free language + tooling surface: diagnostics, completions, hover, navigation, semantic tokens, signature help, formatProgram.
aktion-runtime/eslintThe ESLint processor and presets: configs.recommended for .aktion files, aktionTypeScriptConfig and the aktion/props-literal and aktion/router-literal rules for .aktion.ts / .aktion.js — see Linting.
aktion-runtime/viteThe Vite plugin for .aktion, .aktion.ts and .aktion.js modules (default export), the compile helpers, the TypeScript frontend and emitAktionDeclarations.
aktion-runtime/dslTypes only. Every built-in component, hook and namespace, for .aktion.ts modules — see above.
aktion-runtime/dsl-globalsTypes only. The same names declared ambiently, for a tsconfig without the DOM lib.
aktion-runtime/aktion-modulesTypes only. Ambient declare module "*.aktion" declarations — see above.
aktion-runtime/system_prompt.txt
aktion-runtime/system_prompt_chat.txt
The full and chat-sized system prompts as plain text files, for build steps that read them from disk. From a program, prefer element.getSystemPrompt().
aktion-runtime/skillThe Aktion agent skill (SKILL.md), for tools that load it from the package.

Only aktion-runtime, aktion-runtime/vite and aktion-runtime/eslint ship a CommonJS build; the other JavaScript entries are ESM‑only.

The public types

These are the declarations a host integration actually names. Everything else in the bundle is reachable, but this table is the surface worth importing deliberately:

TypeFromWhat it is
AktionElementaktion-runtimeThe <aktion-app> class. Exported as a value too, so instanceof works.
ComponentSpecaktion-runtime{ name, description, props, render } — one custom built‑in. Pass an array to registerComponents.
PropSpecaktion-runtimeOne entry in a spec’s props. See the field table below.
RenderHelpersaktion-runtimeThe third argument handed to render: renderNode, invoke, setState, resetState, useInstanceState, registerDisposer, router.
ComponentLibraryaktion-runtime{ root, components }. defaultLibrary is one; the language service takes one as an argument.
HttpInterceptorsaktion-runtimeThe onRequest / onResponse / onError bag — see Interceptors.
HttpRequest · HttpResponseaktion-runtimeWhat those hooks receive and return.
ThemeTokensaktion-runtimeEvery themeable token as a flat, all‑string record (colorPrimary, radiusButton, chart1…). ThemeInput = string | Partial<ThemeTokens> is what setTheme accepts.
ResolvedThemeaktion-runtime{ name, tokens } — the output of resolveTheme. See Themes.
GlobalAccessPolicyaktion-runtime"all" | "safe" | readonly string[] — which host globals a program may reach. See Security.
RenderToStringOptions
RenderToStringResult
aktion-runtimeSSR input and output — see SSR.
CompiledProgramaktion-runtimeThe linker artefact from linkProject / compileLite, and what mountCompiled takes.
PromptOptionsaktion-runtimeOptions for getSystemPrompt / generatePrompt (mode, preamble, tools, …).
Diagnostic · Position
CompletionItem · HoverInfo
FormatResult
aktion-runtime/languageThe editor-tooling shapes — see Language surface.
Screen · RenderOptions
A11yViolation
aktion-runtime/testWhat render() returns, what it accepts, and what axe() reports.

GlobalAccessPolicy is the one worth reaching for early. If the program text can come from somewhere you do not fully trust, narrow the global surface before the first render:

import { setGlobalAccessPolicy } from "aktion-runtime";
import type { GlobalAccessPolicy } from "aktion-runtime";

const policy: GlobalAccessPolicy = "safe"; // "all" is the default — full globalThis
setGlobalAccessPolicy(policy);             // data + formatting + encoding only

"safe" drops code execution, DOM, network, and storage from the passthrough; an explicit array grants exactly the names you list and nothing else. Security owns the full trust model.

Typing the host element

<aktion-app> is a custom element, and the package augments HTMLElementTagNameMap for it. That means you do not need a cast — both querySelector and createElement already return AktionElement:

import "aktion-runtime"; // side-effect import: defines the element

const app = document.querySelector("aktion-app"); // AktionElement | null — no cast
app?.setResponse(source);      // replace the whole program
app?.appendChunk(tokenDelta);  // append a streaming tail
app?.setTheme("mui-dark");     // a built-in name, or a Partial<ThemeTokens> map

Import the package for its side effect before you query, or the element is still an HTMLUnknownElement at runtime. The methods and accessors you are most likely to reach for:

MemberSignaturePurpose
setResponse(text: string) => voidReplace the program and re-render from scratch.
appendChunk(chunk: string) => voidAppend a streaming delta. Coerces defensively, so a decoder result is fine.
clear() => voidDrop the program and the rendered tree.
responsestring (get/set)Property form of setResponse.
streamingboolean (get/set)While true, transient parse errors are suppressed instead of dispatched.
showErrorsboolean (get/set)Show or hide the built-in error banner.
setTheme(theme: ThemeInput) => voidApply a built-in theme name or a flat token map.
registerComponents(components: ComponentSpec[], rootName?: string) => voidAdd custom built‑ins; the next getSystemPrompt() advertises them.
registerIcons(icons: Record<string, string>) => voidRegister inline SVG by name, usable anywhere a Font Awesome name is.
registerHttpInterceptors(interceptors: HttpInterceptors) => voidHost‑wide HTTP hooks. Calls merge incrementally.
getSystemPrompt(options?: PromptOptions) => stringBuild the prompt for the active library. { mode: "chat" } for the compact variant.
navigate · route(path: string) => void · string (get)Drive and read the in-app router.
serializeState() => Record<string, unknown>Snapshot reactive state as plain JSON-friendly values.
hydrateState(snapshot: Readonly<Record<string, unknown>>) => voidApply a snapshot; later $state defaults do not overwrite it.
loadSnapshot(payload: { programText: string; state: Record<string, unknown> }) => voidSet program and state atomically — the SSR-hydration entry point.
applyDelta(ops: readonly DeltaOp[]) => string[]Patch the running program; returns advisory warnings.
mountCompiled(compiled: CompiledProgram, state?: Record<string, unknown>) => voidMount a linked multi-file program, skipping the runtime parser.
src · loadFromSrc · sourceIdstring | null · (src: string) => Promise<void> · string | nullLoad a .aktion entry over the network and report which module is mounted.
connectDevtools() => voidAttach to a DevTools hook installed after this element mounted. Idempotent.

DeltaOp is not re-exported

Pass a literal array and let inference do the work. applyDelta is typed against DeltaOp, but that union is not exported from the root entry, so you cannot annotate the variable. An inline literal still checks against every op shape:

const warnings = app.applyDelta([
  { kind: "replace", binding: "count", source: "$count = 5" },
]); // string[] — advisory notes for ops that became no-ops

The other op kinds are patch ({ target, value }), append ({ binding, item }), new ({ source }), and delete ({ binding }). A partial delta always mounts the rest of the patched program rather than throwing.

Host event payloads

The element dispatches three built-in CustomEvents, plus whatever a program raises with $emit. All of them are bubbles: true and composed: true, so a listener on an ancestor sees them:

EventdetailFires when
assistant-message{ message: string }The program asked the host to send a message back to the model.
route-change{ path: string; previousPath: string | null; source: "init" | "hashchange" | "navigate" | "external" }The router committed a navigation. source tells you whether it came from the URL, a navigate() call, or the initial mount.
error{ errors: { line: number; column: number; message: string }[] }Parse, src‑load, or runtime‑budget failures. Not dispatched while streaming is true — a mid-token chunk is expected to be invalid.
your own namewhatever $emit passedAn action or effect called $emit("name", detail). assistant-message, error, and route-change are reserved — see Actions.

The package does not ship an event map, because the fourth row is open‑ended. Declare one for the events your host cares about and hang a small generic helper off it — that is the single place a cast is needed, and you write it once:

interface AktionEventMap {
  "assistant-message": { message: string };
  "route-change": {
    path: string;
    previousPath: string | null;
    source: "init" | "hashchange" | "navigate" | "external";
  };
  "error": { errors: { line: number; column: number; message: string }[] };
  // add your own $emit names here
}

function onAktion<K extends keyof AktionEventMap>(
  el: EventTarget,
  type: K,
  handler: (detail: AktionEventMap[K]) => void,
): () => void {
  const listener = (e: Event) => handler((e as CustomEvent<AktionEventMap[K]>).detail);
  el.addEventListener(type, listener);
  return () => el.removeEventListener(type, listener); // call to unsubscribe
}

Every call site now gets autocompletion on the event name and a correctly-typed detail, with the returned function as the unsubscribe:

const off = onAktion(app, "assistant-message", (detail) => sendToModel(detail.message));
onAktion(app, "route-change", (detail) => analytics.page(detail.path, detail.source));
onAktion(app, "error", (detail) => detail.errors.forEach(reportError));

off(); // detach the assistant-message listener

Typing custom components

A custom built‑in is a ComponentSpec. Its render is (node, props, helpers) => Node: props arrives as Record<string, unknown> because a program may pass it anything, so narrow each value as you read it (callers in .aktion.ts are typed through module augmentation).

import "aktion-runtime";
import type { ComponentSpec } from "aktion-runtime";

const SVG_NS = "http://www.w3.org/2000/svg";

export const Sparkline: ComponentSpec = {
  name: "Sparkline",
  description: "Inline trend line for a series of numbers.",
  props: [
    { name: "values", type: "number[]", positional: true, required: true, description: "The series to plot" },
    { name: "stroke", type: "string", optional: true, description: "Line colour (default: the theme primary token)" },
  ],
  render: (_node, props) => {
    const values = (props.values as number[] | undefined) ?? [];
    const stroke = (props.stroke as string | undefined) ?? "var(--rui-color-primary)";
    // createElement("svg") builds an HTMLUnknownElement that never paints — SVG needs the namespace.
    const svg = document.createElementNS(SVG_NS, "svg");
    svg.setAttribute("viewBox", "0 0 100 24");
    svg.setAttribute("role", "img");
    const max = Math.max(1, ...values);
    const step = 100 / Math.max(1, values.length - 1);
    const line = document.createElementNS(SVG_NS, "polyline");
    line.setAttribute("fill", "none");
    line.setAttribute("stroke", stroke);
    line.setAttribute("stroke-width", "2");
    line.setAttribute("points", values.map((v, i) => `${i * step},${24 - (v / max) * 24}`).join(" "));
    svg.append(line);
    return svg;
  },
};

const app = document.querySelector("aktion-app")!;
app.registerComponents([Sparkline]); // the next getSystemPrompt() advertises it

Reading a theme token through var(--rui-color-primary) rather than a literal colour is what keeps a custom component in step with whichever theme the host applied.

The props array is a PropSpec[]. Every field is consumed by three different systems — the evaluator (argument binding), the validator (unknown-prop errors), and the prompt generator — so filling them in is not documentation, it is behaviour:

FieldTypeEffect
namestringRequired. The key your render reads from props.
typestringRequired. A documentation-and-binding hint ("string", "number", "boolean", "callable", "Node", "Node[]", or a free-form label like "number[]").
positionalbooleanAt most one prop per spec. Marks the slot a bare first argument lands in. A spec with no marker falls back to “first prop is positional”.
requiredbooleanMarker only — the prompt generator renders it without a ?. Not enforced at runtime.
optionalbooleanThe opposite marker, used the same way.
enumreadonly string[]Accepted values. The validator rejects anything else, and the prompt lists them.
aliasesreadonly string[]Extra named-argument spellings that route to this same slot — how tone and variant coexist across the library.
descriptionstringGoes into the generated system prompt verbatim. This is what the model reads.

The helpers argument is fully typed too — renderNode, invoke, setState, resetState, useInstanceState, registerDisposer, and router all carry signatures, so authoring a spec stays type-safe end to end.

Typing HTTP interceptors

Register host‑wide HTTP hooks with registerHttpInterceptors(interceptors). They fire around every $http / $query / $mutation request the program makes, which makes them the right place for auth headers and centralised logging:

HookSignatureContract
onRequest(request: HttpRequest) => HttpRequest | Promise<HttpRequest>Return the request to send — usually a spread copy with extra headers.
onResponse(response: HttpResponse, retry: () => Promise<HttpResponse>) => HttpResponse | Promise<HttpResponse>Inspect or replace the response. Call retry() to re-issue the same request once — the token-refresh hook.
onError(error: unknown, request: HttpRequest) => voidObservation only. The return value is ignored; the error still propagates to the program.
import type { HttpInterceptors } from "aktion-runtime";

const interceptors: HttpInterceptors = {
  onRequest: (req) => ({
    ...req,
    headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
  }),
  onResponse: async (res, retry) => (res.status === 401 ? retry() : res),
  onError: (err, req) => console.error("[http]", req.url, err),
};

app.registerHttpInterceptors(interceptors); // merges with anything already registered

Merging is incremental, so a later call passing only onRequest never clears an existing onResponse. HttpRequest is { url, method, headers, body?, signal?, init? } and HttpResponse is { status, headers, body } — init is the passthrough for any extra fetch option. See HTTP for the program-side view.

The language & tooling surface

aktion-runtime/language is DOM‑free: it parses, validates, formats, and answers editor questions about program text without touching the renderer. Use it for a CI lint step, a Monaco/LSP integration, or a pre-commit formatter.

import { getDiagnostics, formatProgram, defaultLibrary } from "aktion-runtime/language";
import type { Diagnostic, FormatResult } from "aktion-runtime/language";

const diagnostics: Diagnostic[] = getDiagnostics(source, defaultLibrary);
const blocking = diagnostics.filter((d) => d.severity === "error");

const result: FormatResult = formatProgram(source);
if (result.errors.length === 0) writeFileSync(path, result.formatted);

Note the second argument: getDiagnostics, getCompletions, and getHoverInfo all take a ComponentLibrary, because what counts as an unknown component depends on which one is active. Pass defaultLibrary, or the result of mergeLibraries when the host registers custom specs. Formatting is a no‑op when errors is non-empty, so guard on it before overwriting a file.

ExportSignatureReturns
getDiagnostics(source, library) => Diagnostic[]Parse + schema errors, each with line, column, message, severity.
getLintWarnings(source) => Diagnostic[]Advisory-only warnings; already folded into getDiagnostics.
getCompletions(source, position, library) => CompletionItem[]Completions at a 1‑indexed { line, column }.
getHoverInfo(source, position, library) => HoverInfo | null{ contents, kind } for the symbol under the cursor.
getSignatureHelp(source, position, library?) => SignatureHelp | nullActive parameter inside a component call.
formatProgram(source) => FormatResult{ formatted, errors, warnings? }. Idempotent. When the printed text would not be the same program as the source, formatted is the untouched source and warnings says so.
getSemanticTokens(source, library?) => SemanticToken[]Editor highlighting, with semanticTokenTypes / semanticTokenModifiers legends.
navigation setgetDefinition, getReferences, getDocumentSymbols, getDocumentHighlights, getRenameEditsGo‑to‑definition, outline, and rename edits over one program.
getLanguageSpec() => LanguageSpecThe machine-readable grammar, keyword docs, builtin catalog, and snippets.

SSR, importer & DX exports

The main entry also carries server‑rendering and authoring‑tool helpers, all fully typed:

ExportSignaturePurpose
renderToString(program, opts?) => { html, state, head, headAttrs }SSR — markup, a hydration snapshot, and the $head output. program is a compiled program or Aktion text.
renderToStaticMarkup(program, opts?) => stringSSG — the html field only.
renderToTextTree(program, opts?) => { text, errors, ok }An indented outline of the rendered tree, for snapshot tests and LLM feedback loops. program is a compiled program or Aktion text.
htmlToAktion(html) => stringImporter — map common HTML/JSX tags to an Aktion program.
tailwindToSx(classString) => objectMap Tailwind utilities (incl. arbitrary values like w-[327px]) to an sx object; misses land in _unmapped.
cssToSx(cssText) => objectMap a raw CSS declaration string or rule to an sx object.
styledToSx(template) => objectExtract static declarations from a styled‑components / emotion template.
componentSchema(library) => LibrarySchemaStable JSON of every component’s props/types/enums for editor autocomplete. The library argument is required — pass defaultLibrary.
buildGallery(library) => stringSelf‑contained HTML component gallery (Storybook‑style explorer).
suggestComponent(name, library) => string[]“Did you mean” suggestions for an unknown component.

To render a compiled program, pass the program itself — the default import of a .aktion / .aktion.ts / .aktion.js entry, or what compileAktionFile returns. renderToString(app) renders its AST as linked, as mountCompiled does in the browser, and does not re-parse app.source. The string form, renderToString(source), is for plain Aktion text.

Either way it returns four fields, not two. Dropping head and headAttrs is what silently loses your <title>, meta tags, and Open Graph cards from the served page:

import { renderToString } from "aktion-runtime";
import app from "./app.aktion.ts";   // the compiled program

const { html, state, head, headAttrs } = renderToString(app, { path: "/" });

const attrs = Object.entries(headAttrs).map(([k, v]) => `${k}="${v}"`).join(" ");
const page = `<!doctype html>
<html ${attrs}>
  <head>${head}</head>
  <body>
    ${html}
    <script type="application/json" id="aktion-state">${JSON.stringify(state)}</script>
  </body>
</html>`;

Ship state to the browser and hand it back with the same program — element.mountCompiled(app, state) for a compiled program, element.loadSnapshot({ programText, state }) for text — so hydration starts from the server’s values. Head owns the head / headAttrs fields; Deployment covers the serving side.

RenderToStringOptionsTypeDefault
libraryComponentLibrarydefaultLibrary
pathstring"/" — the in-memory router’s initial path
initialStateRecord<string, unknown>none — values hydrate over the program’s own declarations
containerbooleantrue — wrap the output in one container element

renderToString needs a DOM

It renders through the real renderer, not a string builder. Called in bare Node it throws [aktion] renderToString requires a DOM. In Node, register happy-dom or jsdom globals before calling it. Register those globals once at server start‑up; a malformed program then SSRs to an empty container instead of throwing.

The testing entry (aktion-runtime/test) additionally exports within(node) for scoped queries and axe(node) for an accessibility audit — both typed (see Testing and Accessibility).

A typed host wrapper

A small wrapper centralises element creation, streaming, and listener cleanup for any framework host. Because createElement is already typed, the whole thing stays cast‑free except for the one detail read:

import "aktion-runtime";
import type { AktionElement } from "aktion-runtime";

export interface AktionHost {
  el: AktionElement;
  stream(delta: string): void;
  reset(full: string): void;
  dispose(): void;
}

export function mountAktion(parent: HTMLElement): AktionHost {
  const el = document.createElement("aktion-app"); // AktionElement — no cast
  parent.append(el);
  const onError = (e: Event) => {
    const { errors } = (e as CustomEvent<{ errors: { message: string }[] }>).detail;
    errors.forEach((x) => console.error("[aktion]", x.message));
  };
  el.addEventListener("error", onError);
  return {
    el,
    stream: (delta) => el.appendChunk(delta),
    reset: (full) => el.setResponse(full),
    dispose: () => { el.removeEventListener("error", onError); el.remove(); },
  };
}

Returning dispose matters more than it looks: a framework that remounts — React strict mode, an HMR boundary, a route change — will call it, and an un-removed error listener is the usual source of duplicate console noise.

Rules of thumb

Next