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:
{
"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:
{
"lit-plugin.all": true
}
- Webstorm / IntelliJ highlight lit-html templates natively
- Keep
htmlimported frommates— re-exports keep IDE type info intact - Prefer explicit
.tsextensions in imports (matchesallowImportingTsExtensions)
Strict-mode tips
- DOM refs from
ref()areHTMLElement | nulluntil mounted — narrow before use or read insideonMount - 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