TypeScript

Silgi reads your files and writes the types for them: route names and params, the data of each load, link targets, the input of every handler route, the keys of the config, the environment. silgi typegen writes them to .silgi/; dev, build, generate and typecheck run it first, and the starters run it after install. Until it has run, every registry is empty and the types stay loose, so a fresh checkout still compiles.

silgi typegen

Nothing here is a second API. The types are declarations that merge into silgi/app, so the names you already use (useRoute, useData, $fetch, Link, navigate) become exact.

The examples on this page are one small React app, landing/samples/typed, which the test suite type-checks against the files silgi typegen writes for it. The same types exist in the other libraries; the examples use @silgi/react.

What typegen writes

File in .silgi/Gives you
tsconfig.json, tsconfig.app.json, tsconfig.server.json, tsconfig.shared.json, tsconfig.node.jsonthe compiler options and aliases; see the contexts
types/routes.d.tsroute names and params (SilgiRoutes), link targets (SilgiRoutePaths, SilgiLinkProtocols), layout and middleware names
types/pages/**/+types/<page>.d.tsone route types file for each page
types/loaders.d.tsthe data of each load (SilgiLoaders)
types/search.d.ts, types/errors.d.tsthe output of each page's search schema, and the Errors each page exports
types/server.d.tsfor each handler route its URL, methods, input and result, for $fetch and useFetch
types/config.d.ts, types/env.d.ts, types/env-vars.d.tsthe runtime config and app config, import.meta, and the environment
.env.examplea template for app/env.ts

Route types files

For every page, silgi typegen writes a file you import from ./+types/<page>, next to the page. It exports one namespace, Route, of types only. A layout has no params and gets none.

import { getPost } from "#server";
import type { Route } from "./+types/[slug]";

export const load = (async ({ params, parent }) => {
  const blog = await parent();
  const post = await getPost({ id: params.slug });
  return { post, section: blog?.section };
}) satisfies Route.Load;

satisfies Route.Load is the whole use. It types ctx.params, ctx.search and ctx.parent() for this route, and leaves the return type to inference, so the data stays exact. Do not write : Route.Load; the annotation fixes the return type to unknown.

MemberIs
Route.Namethe route name, "blog-slug"
Route.Paramsthe params: { slug: string }; a catch-all is a string[], an optional segment an optional key
Route.LoadContext, Route.Load<T>LoadContext with params, search and parent() narrowed to this route; Load<T> is the function type. Use it to name a return type: Load<Post>
Route.LoadDatathe data of this route's load, as useData() returns it
Route.ParentDatawhat parent() resolves to
Route.Matchesthe chain useMatches() returns for this page, each entry with its own params and data
Route.Errorsthe Errors type the page exports, else {}; see errors

Annotating a load with Route.LoadData as its own return type is a circular reference. Name the type with Load<T> instead.

The files live in .silgi/types/pages/ and mirror the path of the page: app/pages/blog/[slug].tsx is .silgi/types/pages/app/pages/blog/+types/[slug].d.ts. rootDirs in .silgi/tsconfig.json makes ./+types/[slug] resolve beside the page, in tsc, vue-tsc and svelte-check. A tsconfig of your own that sets rootDirs replaces that list. A .vue or .svelte page imports Route in its <script lang="ts"> the same way.

There is no PageProps type. No adapter passes params or data to a page as props; a page reads useRoute() and useData().

Params and data of the page

Route.Name is the type argument, so no route name is written by hand and it follows the file name:

import { useData, useRoute } from "@silgi/react";
import type { Route } from "./+types/[slug]";

export type Errors = { 404: { slug: string } };

export default function Post() {
  const { params } = useRoute<Route.Name>();
  const data = useData<Route.Name>();
  return (
    <h1>
      {params.slug}: {data.post.title}, published {data.post.publishedAt.getFullYear()}
    </h1>
  );
}
  • useData<Route.Name>() is the data of that route's load. post.publishedAt is still a Date: the payload carries it (Serialization).
  • useRoute<Route.Name>() is the route with params of that route. It is the same as useRoute("blog-slug"). It is a type and nothing else: it never throws, also when the page on screen is a child of that route, an about___fr copy of it, or a page that is leaving. Its name is any route name, so check route.name where the difference matters.
  • useRoute() with no argument is the route as before: params is the union over every route.
  • Before silgi typegen has run, params stays loose.

The paths your app answers are types. to of Link, and navigate, push, replace, prefetch, navigateTo and redirect take a literal path that no page, route, public file or rule can match as a type error:

import { useRouter } from "@silgi/react";
import type { AppPath } from "silgi/app";

export const menu = [
  { to: "/", label: "Home" },
  { to: "/blog/hello", label: "A post" },
  { to: "/search?page=2", label: "Search" },
] satisfies readonly { to: AppPath; label: string }[];

export function useGoto() {
  const router = useRouter();
  return {
    toPost: (slug: string) => router.navigate(`/blog/${slug}`),
    toRoute: (slug: string) => router.navigate({ name: "blog-slug", params: { slug } }),
    toExternal: () => router.navigate("https://example.com", { external: true }),
  };
}
app/links.ts(6,45): error TS2345: Argument of type '"/blgo/x"' is not assignable to parameter of type 'SearchUpdate | PathPattern | RouteLocation'.
  • A path may end in a query or a hash: "/search?page=2", "/blog/hello#top". A { name, params } location is checked against SilgiRoutes.
  • An external target needs { external: true } on navigate, push, replace, navigateTo and redirect, as it needs at run time. The type says why: "https://x.test needs { external: true }". Link to="https://…" needs no flag. A scheme is accepted when app.linkProtocols lists it.
  • A string variable compiles; the run time still refuses a bad target. The check covers what the build can know, a literal or a template literal.
  • A dynamic segment is ${string}, and a template literal type cannot exclude /. A page at the root such as [slug].tsx makes every path match, and the check then protects only the static segments.
  • A component that takes a link target is generic, so it checks its prop as Link does:
import { Link } from "@silgi/react";
import type { LinkTarget } from "silgi/app";

export function NavItem<T extends string>({ to, label }: { to: LinkTarget<T>; label: string }) {
  return <Link to={to}>{label}</Link>;
}

export const nav = <NavItem to="/blog/hello" label="A post" />;
  • There is no linkOptions(). To check a menu written as data, use satisfies readonly AppPath[] (the first sample above).
  • typescript.typedPaths: false turns the path check off; AppPath and LinkTarget are then string.

$fetch and useFetch

A literal URL that a handler route answers is checked against the handler's input. The type is what the caller sends, so a key with a default is optional:

import { $fetch, isSilgiError, type JsonSerialized, type RouteInput, type Serialized } from "silgi/app";
import { createPost, getPost } from "#server";

export type PostInput = RouteInput<"/api/posts", "POST">;

export async function publish(title: string) {
  const created = await $fetch("/api/posts", { method: "POST", body: { title } });
  return created.id;
}

export async function over(id: string) {
  const post = await $fetch("/api/posts/1");
  const same: JsonSerialized<{ publishedAt: Date }> = { publishedAt: post.publishedAt };
  const fromStub: Serialized<{ publishedAt: Date }> = await getPost({ id });
  return [same, fromStub];
}

export async function tryCreate(title: string) {
  try {
    return await createPost({ title, draft: false });
  } catch (error) {
    if (isSilgiError(error, 409, createPost)) return error.data?.existing;
    throw error;
  }
}

The first call omits draft, which the schema defaults, and compiles. A body of { title: 1 } fails at the call with Type 'number' is not assignable to type 'string'.

  • The types never refuse a call that works at run time. A URL in a string variable, a union, a call with a baseURL, a method in a variable, a generic wrapper and a FetchOptions variable are all loose. Only a call that is known to be wrong, a literal URL and method with a wrong field, a wrong type or a missing required field, fails at the call.
  • A method the matched path does not take is an error, since the router answers 405 there.
  • RouteInput<Path, Method = "GET"> is the input a route takes. The one input of a handler is the body for POST, PUT, PATCH, DELETE and QUERY, and the query for GET, HEAD, OPTIONS and for a raw handler. It is unknown for a route without input.
  • Query fields are typed from the schema's input as written. A GET schema read through $fetch has to coerce strings, and the types cannot check that.
  • A call with a type argument, $fetch<Post>("/x"), is unchecked and its result is Post.
  • useFetch and useLazyFetch use the same check. FetchData<"/api/posts/1"> is the result type of a route.

Serialization

The payload carries more than JSON and a route's JSON answer carries less. Two mapped types in silgi/app say which:

Serialized<T>: payload, useData, a handler callJsonSerialized<T>: a route's JSON answer, $fetch, useFetch
Date, URLkeptstring
undefinedkeptthe property is dropped
Map, Set, typed arrays, RegExp, bigint, Errorkeptrefused
a type with toJSON()refused unless registered in app/transport.tsthe result of toJSON()
a type registered in app/transport.tskeptrefused unless it has toJSON()
functions, symbols, other class instancesNotSerializable<"data.save is a function">refused
  • useData(), LoaderData<N>, Route.LoadData, useAsyncData(fn, input), useAction(fn).state and the result of a handler stub are Serialized. A route's $fetch and useFetch result is JsonSerialized: a Date is a string there, as it is on the wire.
  • NotSerializable<Message> is a type with no data, so using the leaf fails and the message is in the error: useData().post.save() fails with a message that starts not serializable: data.post.save is a function. The error is where the leaf is used, and in silgi typecheck. satisfies Route.Load cannot reject a return type that it leaves to inference. The run time still fails with the path of the value; see Data.
  • A limit: TypeScript cannot tell a class instance that holds only data from an object literal, so Serialized<new Price()> is accepted and the serializer throws, naming the path.

Errors

A handler lists the data of each error status it throws, and the caller narrows on it:

import { createError, defineHandler } from "silgi/server";
import { schema } from "./schema.ts";

export const getPost = defineHandler({
  method: "GET",
  route: "/api/posts/:id",
  input: schema<{ id: string }>(),
  handler: ({ id }) => ({ id, title: "Hello", publishedAt: new Date("2026-01-01") }),
});

export const createPost = defineHandler({
  method: "POST",
  route: "/api/posts",
  input: schema<{ title: string; draft: boolean }, { title: string; draft?: boolean }>(),
  errors: { 409: schema<{ existing: string }>() },
  handler: ({ title, draft }) => {
    if (title === "taken") throw createError({ status: 409, data: { existing: "taken" } });
    return { id: "1", title, draft };
  },
});

isSilgiError(error, 409, createPost) narrows error.data to { existing: string } in the call above. It is checked for a stub call, useAction(fn).error and a call in process on the server. A status the handler does not declare compiles, with data: unknown. data is optional, since nothing validates it at run time. $fetch and useFetch errors are not narrowed. See Handlers.

A page declares the errors that can show on its route as a type export, not a value, so nothing ships to the browser:

import { useError } from "@silgi/react";

export default function ErrorPage() {
  const error = useError();
  if (error?.route === "blog-slug" && error.status === 404) return <p>No post called {error.data?.slug}</p>;
  return <p>Something went wrong ({error?.status})</p>;
}

The page app/pages/blog/[slug].tsx exports export type Errors = { 404: { slug: string } } (in a .vue page in the plain <script lang="ts">, in a .svelte page in <script module lang="ts">). error.route is the name of the page route that the error's URL matches, set wherever the error page is shown, and useError() is a union on route and status. It is absent when no page matches. error.data is optional. A route name that does not exist is a type error: error.route === "nope".

ctx.search in a load is the output of the page's search schema, validated (Routing). With the schema in the sample, ctx.search.page is a number:

import type { StandardSchemaV1 } from "silgi/server";
import type { Route } from "./+types/search";

export const search: StandardSchemaV1<Record<string, unknown>, { page: number }> = {
  "~standard": {
    version: 1,
    vendor: "docs",
    validate: (input) => ({ value: { page: Number((input as Record<string, unknown>).page ?? 1) } }),
  },
};

export const nextPage = (ctx: Route.LoadContext) => ctx.search.page + 1;

export default function Search() {
  return <h1>Search</h1>;
}

ctx.query is the raw strings.

parent()

parent() resolves to the data of the nearest ancestor page that has a load, or of the layout's load when no page above it has one, or undefined. The [slug].load.ts sample above reads blog?.section, typed from blog.load.ts. When meta.layout is not a string literal, the type is the union over every layout with a load.

Route context

Route middleware can hand a value to the load of the same navigation without sending it to the browser. A key is created with createRouteContext, so a class instance or a function can travel:

import { createRouteContext } from "silgi/app";

export interface User {
  name: string;
  can(permission: string): boolean;
}

export const userContext = createRouteContext<User | null>("user", null);
import { defineRouteMiddleware } from "silgi/app";
import { userContext } from "../context.ts";

export default defineRouteMiddleware((to, from, ctx) => {
  ctx.context.set(userContext, { name: "Ada", can: (permission) => permission === "read" });
});
import { userContext } from "../context.ts";
import type { Route } from "./+types/me";

export const load = ((ctx) => ({ name: ctx.context.get(userContext)?.name })) satisfies Route.Load;
  • get of a key that was not set and has no default throws Route context "user" is not available. The default of the sample is null.
  • It is not available while a data prefetch runs, and in a load that is omitted from the payload (serialization: "never"), because middleware runs in neither. Reading it there throws … is not available while prefetching or … while hydrating an omitted loader, whatever the default. A prefetch that throws is dropped, and the navigation runs the load after its middleware.
  • A value lives for one navigation. A refresh runs the route middleware again and gives the loads a fresh map.
  • There is no useRouteContext for components: the middleware does not run again in the browser for the first request, so a component would render something else there. Read what the load returned, or use state.
  • A key created twice with one name throws Route context "user" is created twice in dev, when the two calls are in different files.
  • This is not the ctx.context of a handler, which is another object.

Page meta, layouts and state keys

Once the app has layouts and named middleware, meta.layout is keyof SilgiLayouts | false and meta.middleware is a name from SilgiMiddlewareNames, a function, or a list of them. A misspelt name is a type error:

import { useData } from "@silgi/react";
import type { PageMeta } from "silgi/app";
import type { Route } from "./+types/me";

export const meta = { layout: "admin", middleware: "auth", title: "Me" } satisfies PageMeta;

export default function Me() {
  return <h1>{useData<Route.Name>().name}</h1>;
}

To declare your own meta keys, augment SilgiPageMeta. PageMeta is a type and cannot be augmented, and once SilgiPageMeta has a key, a meta key it does not declare fails, so a typo cannot pass:

declare module "silgi/app" {
  interface SilgiPageMeta {
    title?: string;
  }
}

export {};

On the read side, useRoute().matched[0].meta.title is string | undefined, because a page may have no meta. Only the app declares meta keys, never a module.

defineState gives a state key its type, so useState(sessionState) is Session | null with no type argument. String keys still work. The init function runs once, for the first reader.

import { defineState } from "silgi/app";

export interface Session {
  name: string;
}

export const sessionState = defineState<Session | null>("session", () => null);
import { useState } from "@silgi/react";
import { sessionState } from "./state.ts";

export function SessionBadge() {
  const [session, setSession] = useState(sessionState);
  return <button onClick={() => setSession({ name: "Ada" })}>{session?.name ?? "Sign in"}</button>;
}

Actions and forms

useAction(fn) and useForm(fn) are typed from the handler, so there is nothing to write for them: state, failure and error follow input, the return type and errors. See Forms and login.

The four contexts

Silgi writes one tsconfig for each place your code runs, and your root tsconfig.json references them:

FileIncludestypes
.silgi/tsconfig.app.jsonapp/, shared/, .silgi/typesbun
.silgi/tsconfig.server.jsonserver/, shared/, .silgi/types-serverbun
.silgi/tsconfig.shared.jsonshared/, .silgi/types-sharednone
.silgi/tsconfig.node.jsonsilgi.config.ts, app/app.config.ts and the type files of modulesbun

.silgi/tsconfig.json is the same options as one program, and your tsconfig.json extends it so Bun resolves ~, #shared and the other aliases for bun test. Use tsc -b: tsc --noEmit -p on a file that only has references checks nothing.

  • What a context refuses today is Bun in shared/, which has no Bun types. Every context keeps the DOM library, because Silgi ships TypeScript sources whose server and config types reach its client code, so document in server/ compiles.
  • #server is the door of the app, not an alias you can point somewhere else. #server/<path> in server code fails the build; use a relative import.
  • Solid needs typescript: { tsConfig: { jsxImportSource: "solid-js" } }, which its starter has.

Options

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  typescript: {
    strict: true,
    tsConfig: { noUncheckedIndexedAccess: true },
    typedPaths: true,
  },
  build: { typecheck: true },
});
KeyDefault
typescript.stricttruecompilerOptions.strict in all five files
typescript.tsConfigan object merged into the compilerOptions of all five files, after strict and the context's own keys, so it can replace lib or types. Its paths are added to the generated ones and its rootDirs appended
typescript.typedPathstruefalse keeps link targets typed string
build.typecheckfalserun silgi typecheck before bundling; a type error stops the build

A module changes one context in the prepare:types hook, which gets { app, server, shared, node } before the files are written (Modules).

silgi typecheck

silgi typecheck writes the types, then runs your own checker from the node_modules of the app: tsc -b --noEmit for React, Preact, Solid and HTML, vue-tsc -b --noEmit for Vue, and svelte-check on the app context plus tsc -b on the other three for Svelte. It prints the checker's errors and ends with Type errors: <n> and exit code 1, or Type check passed.

silgi typecheck
silgi typecheck --mode staging
silgi build --typecheck
  • A missing checker is an error that says what to install: Install vue-tsc to type-check .vue files (bun add -d vue-tsc).
  • vue-tsc and svelte-check need Node 24 or later on PATH. Bun's node shim is skipped, because vue-tsc under it checks nothing and passes.
  • --mode <name> loads the config of that mode, since a block may change a typed key.
  • silgi build --typecheck and build.typecheck: true run the same check after typegen and before bundling.
  • The starters' typecheck script is silgi typecheck.