MATES / Docs

TypeScript

Mates is written in TypeScript and typed end to end — recommended config, typing components, atoms, and props.

Why TypeScript with Mates

Mates templates are tagged template literals — the TypeScript compiler sees them as plain expressions. There is no JSX transform, no plugin, and no generated types to keep in sync. Projects scaffolded with create-mates ship a strict tsconfig that matches how the framework expects modules to resolve.

Recommended tsconfig

Projects scaffolded with create-mates ship this configuration. The important parts: strict mode on, bundler module resolution, and allowImportingTsExtensions so .ts imports stay explicit:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "noEmit": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

Typing components and props

A component is a function whose props come in through a props-getter. Annotate the props object type and the rest flows — x() enforces that callers pass exactly those props:

import { atom, html, x } from "mates";
import type { Props } from "mates";

interface UserCardProps {
  userId: string;
  highlight?: boolean;
}

export const UserCard = (propsFn: Props<UserCardProps>) => {
  const name = atom("");

  return () => html`
    <div class="card ?highlight">
      ${name()} — ${propsFn().userId}
    </div>
  `;
};

// x() type-checks the props:
x(UserCard, { userId: "42", highlight: true });

Read props inside the inner render function via propsFn() — that keeps re-renders reactive when a parent passes new values. Never destructure propsFn() in the outer function (it runs once and would freeze values). See Mental Model.

Typing state

atom() infers its value type from the initial value. Pass an explicit type parameter for unions, interfaces, or empty collections:

import { atom, mapAtom, store } from "mates";

interface Todo {
  id: string;
  title: string;
  done: boolean;
}

const count = atom(0);                    // Atom<number>
const todo = atom<Todo | null>(null);     // explicit union
const items = atom<Todo[]>([]);           // empty array needs a hint

// mapAtom is a reactive Map:
const todoMap = mapAtom<string, Todo>([]);

Actions are typed through their return value — asyncAction lifts the resolved type into .data(), so consumers get Todo[], not any.

Editor experience

Templates are lit-html. The lit-plugin extension (VS Code: runem.lit-plugin) understands html tags and gives you syntax highlighting, tag completion, and binding checks inside templates:

.vscode/settings.json
{
  "lit-plugin.all": true
}
  • Webstorm / IntelliJ highlight lit-html templates natively
  • Keep html imported from mates — re-exports keep IDE type info intact
  • Prefer explicit .ts extensions in imports (matches allowImportingTsExtensions)

Strict-mode tips

  • DOM refs from ref() are HTMLElement | null until mounted — narrow before use or read inside onMount
  • Event handlers receive typed events; annotate the target when casting: (e.target as HTMLInputElement).value
  • localStorage-backed atoms parse JSON — type them explicitly: lsAtom<Todo[]>('todos', [])
  • Generic components: put the type parameter on the outer function, not the render function