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:
| File | Language | Compiled by |
|---|---|---|
app.aktion | The Aktion DSL | the Aktion parser |
app.aktion.js | JavaScript that keeps to the Aktion subset | the JavaScript frontend, then the parser |
app.aktion.ts | The same, with TypeScript types | ts-blank-space erases the types, then the JavaScript frontend |
utils.ts / utils.js | Native code | Vite — 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)
| Surface | How it is typed |
|---|---|
| Props | Each 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. |
| Callbacks | Exactly 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 back | Generic, inferred from the data you pass: VirtualList, VirtualGrid, Col, Draggable, Mount, Calendar, CalendarView, ActivityLog, Gallery, FieldRepeater, NotificationBell, PresenceAvatars, SegmentedControl, ToggleGroup. |
| DOM values | DomElement, DomFile, DomEvent, DomKeyboardEvent, DomMouseEvent, DomPointerEvent, DomFocusEvent, DomDragEvent, DomWheelEvent, DomClipboardEvent, DomInputEvent. |
sx, animate, role | SxProps, AnimateValue and AriaRole, printed from the runtime’s own tables. class / className take a string or an array; style takes a string. |
$theme, icons | ThemeConfig, with one key set per token group; AktionIconName for icon names. |
$util, $toast, $head | Generic helpers (the element type flows through); the value sets each one honours. |
| Data, stores, forms, routes | A 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:
| Rule | Why |
|---|---|
Declare atoms that change with let | const $x cannot be reassigned (TS2588). |
| Change an imported atom through an exported action | An import is a read-only binding in TypeScript (TS2632). |
Import types with import type | Erasure 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 decorators | They have runtime meaning and cannot be erased; Aktion reports each one at its position. |
Write the .ts extension | Aktion 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:
- every local variable gets its own name, so a block’s
letstays in its block, a local never overwrites a module binding, a helper reads the module’s variable rather than its caller’s, and a parameter wins over a top-level function of the same name; - a function declared inside another becomes a
constfunction at the same place, so anonClickcan call it later (it is not hoisted); - a function body that does not
returnreturnsundefined, as in JavaScript, instead of its last expression; - a module-level
const Card = (…) => …with a capitalised name is a component, so hooks and per-instance state work in it; - a call to one of your components declared in a
.aktion.ts/.aktion.jsmodule binds its arguments by position, as JavaScript does —KVRow({ key: "a", value: "1" })hands the whole object to the first parameter, however the import spells the path ("./cards"or"./cards.aktion.ts").key:still sets the instance identity, from an extra object after the declared parameters (ItemRow(item, { key: item.id })forfunction ItemRow(item)) or from a last argument whose only property iskey(see Keys and children), and the object itself is still passed as JavaScript passes it. Calls written in.aktion, and calls to components declared in.aktion, keep Aktion’s named props; - a
...restparameter on a component gathers every remaining argument into an array.
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):
| Code | Rejected | Instead |
|---|---|---|
| E101, E102 | await, async — bodies run synchronously | .then(…), or $http(…) and its .onDone |
| E103, E104 | this, super, debugger, arguments, var | parameters, a rest parameter, let / const |
| E105, E106 | a local reassigned after a closure captured it; a closure using a local declared after it — closures copy their scope when they are created | a $state atom, $ref(…), or an object box; declare first |
| E107 | changing 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 rebuild | build 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, E110 | per-instance state or hooks anywhere but the top of a component (or hook) body | move them up |
| E111 | a capitalised function used as a value — it is a component | name the helper in camelCase |
| E112, E115 | names the runtime owns (route, params, a binding named like a built-in component, __a1_x / __l1_x) | rename |
| E113, E117–E121 | block 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 effect | see each message |
| E114, E124–E126 | a derived atom that is also assigned, destructuring a $store / $form handle, assigning an undeclared name, components or hooks declared inside functions | see each message |
| E122, E123 | an aliased aktion-runtime/dsl import ({ Button as B }); a name imported from aktion-runtime/dsl and also declared in the module | import 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 literals | list every arm, with a string path, in the object literal at the call: $router({ "/": Home(), default: NotFound() }) |
| W201, W202 | Warnings: a module-level value from Date.now(), Math.random() or crypto (recomputed each render); a state-writing function called while rendering | compute 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:
- division by zero and other
NaNresults are0; two numeric strings compare as numbers;nullandundefinedconcatenate as""and objects as JSON; - an error thrown inside a method of a host value becomes
nullinstead of reaching yourcatch— your ownthrowis caught normally; - property access on an array plucks (
users.name), and reading a method as a value (const up = s.toUpperCase) givesundefined; - a call to an unknown function renders a placeholder instead of throwing —
tsccatches the unknown name; cond && Text("x")rendersfalsewhencondis false — usecond ? Text("x") : null(the types reject the boolean child);- a props bag must be an object literal at the call:
Button("Go", opts)bindsoptstoonClick. The ESLint ruleaktion/props-literalflags it, unlessoptsis typedany(as every untyped parameter of a.aktion.jsmodule is), because then nothing says it holds props; - the other way round, in a call with two or more arguments the last object literal is read as the props bag as soon as one of its keys is a prop name, even where TypeScript matched it to an object parameter:
HTMLTag("section", { "data-x": "1", id: "a" })renders<section id="a">and dropsdata-x. Pass{ attributes: { … }, id: "a" }instead, and move any argument after the object into it too (HTMLTag("div", { attributes: { … }, class: "hero", children: [ … ] })), since no overload takes arguments after the props;aktion/props-literalflags this too.
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:
- a component, with one overload per way a call binds: its parameters by
position, followed by children, or a named-props object
(
TodoRowProps— the parameters by name,key, and named slots) after any prefix of them; - an action or hook, with its arity checked;
- a parameter with a literal default, typed from it —
tone = "muted"isstring | null, because onlyundefinedselects the default; - an atom, typed from its initializer (literals, array and object literals,
$http/$query/$mutation/$form/ … resources) and widened by the values the module assigns to it.
// 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:
| Setting | Value | Why |
|---|---|---|
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 resolvers | TypeScript enforces the pairing itself: bundler with module: "commonjs" is rejected as TS5095 before it ever looks at an import. |
lib | must 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. |
strict | true | Optional, 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:
| Import | Provides |
|---|---|
aktion-runtime | The runtime: registers <aktion-app> on import, plus registerComponents, the themes, the SSR helpers, and every core type. |
aktion-runtime/test | The Testing-Library-style API (render, renderComponent, within, axe, fetch mocks). See Testing. |
aktion-runtime/devtools | The DevTools backend and in-page panel. See DevTools. |
aktion-runtime/coverage | The 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/language | DOM‑free language + tooling surface: diagnostics, completions, hover, navigation, semantic tokens, signature help, formatProgram. |
aktion-runtime/eslint | The 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/vite | The Vite plugin for .aktion, .aktion.ts and .aktion.js modules (default export), the compile helpers, the TypeScript frontend and emitAktionDeclarations. |
aktion-runtime/dsl | Types only. Every built-in component, hook and namespace, for .aktion.ts modules — see above. |
aktion-runtime/dsl-globals | Types only. The same names declared ambiently, for a tsconfig without the DOM lib. |
aktion-runtime/aktion-modules | Types only. Ambient declare module "*.aktion" declarations — see above. |
aktion-runtime/system_prompt.txtaktion-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/skill | The 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:
| Type | From | What it is |
|---|---|---|
AktionElement | aktion-runtime | The <aktion-app> class. Exported as a value too, so instanceof works. |
ComponentSpec | aktion-runtime | { name, description, props, render } — one custom built‑in. Pass an array to registerComponents. |
PropSpec | aktion-runtime | One entry in a spec’s props. See the field table below. |
RenderHelpers | aktion-runtime | The third argument handed to render: renderNode, invoke, setState, resetState, useInstanceState, registerDisposer, router. |
ComponentLibrary | aktion-runtime | { root, components }. defaultLibrary is one; the language service takes one as an argument. |
HttpInterceptors | aktion-runtime | The onRequest / onResponse / onError bag — see Interceptors. |
HttpRequest · HttpResponse | aktion-runtime | What those hooks receive and return. |
ThemeTokens | aktion-runtime | Every themeable token as a flat, all‑string record (colorPrimary, radiusButton, chart1…). ThemeInput = string | Partial<ThemeTokens> is what setTheme accepts. |
ResolvedTheme | aktion-runtime | { name, tokens } — the output of resolveTheme. See Themes. |
GlobalAccessPolicy | aktion-runtime | "all" | "safe" | readonly string[] — which host globals a program may reach. See Security. |
RenderToStringOptionsRenderToStringResult | aktion-runtime | SSR input and output — see SSR. |
CompiledProgram | aktion-runtime | The linker artefact from linkProject / compileLite, and what mountCompiled takes. |
PromptOptions | aktion-runtime | Options for getSystemPrompt / generatePrompt (mode, preamble, tools, …). |
Diagnostic · PositionCompletionItem · HoverInfoFormatResult | aktion-runtime/language | The editor-tooling shapes — see Language surface. |
Screen · RenderOptionsA11yViolation | aktion-runtime/test | What 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:
| Member | Signature | Purpose |
|---|---|---|
setResponse | (text: string) => void | Replace the program and re-render from scratch. |
appendChunk | (chunk: string) => void | Append a streaming delta. Coerces defensively, so a decoder result is fine. |
clear | () => void | Drop the program and the rendered tree. |
response | string (get/set) | Property form of setResponse. |
streaming | boolean (get/set) | While true, transient parse errors are suppressed instead of dispatched. |
showErrors | boolean (get/set) | Show or hide the built-in error banner. |
setTheme | (theme: ThemeInput) => void | Apply a built-in theme name or a flat token map. |
registerComponents | (components: ComponentSpec[], rootName?: string) => void | Add custom built‑ins; the next getSystemPrompt() advertises them. |
registerIcons | (icons: Record<string, string>) => void | Register inline SVG by name, usable anywhere a Font Awesome name is. |
registerHttpInterceptors | (interceptors: HttpInterceptors) => void | Host‑wide HTTP hooks. Calls merge incrementally. |
getSystemPrompt | (options?: PromptOptions) => string | Build 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>>) => void | Apply a snapshot; later $state defaults do not overwrite it. |
loadSnapshot | (payload: { programText: string; state: Record<string, unknown> }) => void | Set 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>) => void | Mount a linked multi-file program, skipping the runtime parser. |
src · loadFromSrc · sourceId | string | null · (src: string) => Promise<void> · string | null | Load a .aktion entry over the network and report which module is mounted. |
connectDevtools | () => void | Attach 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:
| Event | detail | Fires 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 name | whatever $emit passed | An 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:
| Field | Type | Effect |
|---|---|---|
name | string | Required. The key your render reads from props. |
type | string | Required. A documentation-and-binding hint ("string", "number", "boolean", "callable", "Node", "Node[]", or a free-form label like "number[]"). |
positional | boolean | At 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”. |
required | boolean | Marker only — the prompt generator renders it without a ?. Not enforced at runtime. |
optional | boolean | The opposite marker, used the same way. |
enum | readonly string[] | Accepted values. The validator rejects anything else, and the prompt lists them. |
aliases | readonly string[] | Extra named-argument spellings that route to this same slot — how tone and variant coexist across the library. |
description | string | Goes 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:
| Hook | Signature | Contract |
|---|---|---|
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) => void | Observation 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.
| Export | Signature | Returns |
|---|---|---|
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 | null | Active 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 set | getDefinition, getReferences, getDocumentSymbols, getDocumentHighlights, getRenameEdits | Go‑to‑definition, outline, and rename edits over one program. |
getLanguageSpec | () => LanguageSpec | The 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:
| Export | Signature | Purpose |
|---|---|---|
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?) => string | SSG — 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) => string | Importer — map common HTML/JSX tags to an Aktion program. |
tailwindToSx | (classString) => object | Map Tailwind utilities (incl. arbitrary values like w-[327px]) to an sx object; misses land in _unmapped. |
cssToSx | (cssText) => object | Map a raw CSS declaration string or rule to an sx object. |
styledToSx | (template) => object | Extract static declarations from a styled‑components / emotion template. |
componentSchema | (library) => LibrarySchema | Stable JSON of every component’s props/types/enums for editor autocomplete. The library argument is required — pass defaultLibrary. |
buildGallery | (library) => string | Self‑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.
RenderToStringOptions | Type | Default |
|---|---|---|
library | ComponentLibrary | defaultLibrary |
path | string | "/" — the in-memory router’s initial path |
initialState | Record<string, unknown> | none — values hydrate over the program’s own declarations |
container | boolean | true — 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
- Set
moduleResolutiontobundlerornodenextfirst — every other import problem on this page is downstream of that one setting. - Do not cast
querySelector("aktion-app"). The tag‑name map is augmented for you; a cast only hides the moment the import goes missing. - Declare one
AktionEventMapper host and never caste.detailat a call site again. - Narrow
propsinside arenderwithas T | undefinedplus a default. The DSL cannot guarantee a type, so the spec is a hint, not a contract. - Destructure all four fields from
renderToString. Ignoringheadis a silent SEO regression, not a type error. - Reach for
aktion-runtime/languagein CI. It needs no DOM, so a lint step costs no happy‑dom setup.