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.json | the compiler options and aliases; see the contexts |
types/routes.d.ts | route names and params (SilgiRoutes), link targets (SilgiRoutePaths, SilgiLinkProtocols), layout and middleware names |
types/pages/**/+types/<page>.d.ts | one route types file for each page |
types/loaders.d.ts | the data of each load (SilgiLoaders) |
types/search.d.ts, types/errors.d.ts | the output of each page's search schema, and the Errors each page exports |
types/server.d.ts | for 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.ts | the runtime config and app config, import.meta, and the environment |
.env.example | a 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.
| Member | Is |
|---|---|
Route.Name | the route name, "blog-slug" |
Route.Params | the 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.LoadData | the data of this route's load, as useData() returns it |
Route.ParentData | what parent() resolves to |
Route.Matches | the chain useMatches() returns for this page, each entry with its own params and data |
Route.Errors | the 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'sload.post.publishedAtis still aDate: the payload carries it (Serialization).useRoute<Route.Name>()is the route withparamsof that route. It is the same asuseRoute("blog-slug"). It is a type and nothing else: it never throws, also when the page on screen is a child of that route, anabout___frcopy of it, or a page that is leaving. Itsnameis any route name, so checkroute.namewhere the difference matters.useRoute()with no argument is the route as before:paramsis the union over every route.- Before
silgi typegenhas run,paramsstays loose.
Link targets
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 againstSilgiRoutes. - An external target needs
{ external: true }onnavigate,push,replace,navigateToandredirect, 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 whenapp.linkProtocolslists it. - A
stringvariable 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].tsxmakes 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
Linkdoes:
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, usesatisfies readonly AppPath[](the first sample above). typescript.typedPaths: falseturns the path check off;AppPathandLinkTargetare thenstring.
$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
stringvariable, a union, a call with abaseURL, a method in a variable, a generic wrapper and aFetchOptionsvariable 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 oneinputof a handler is the body forPOST,PUT,PATCH,DELETEandQUERY, and the query forGET,HEAD,OPTIONSand for arawhandler. It isunknownfor a route withoutinput.- Query fields are typed from the schema's input as written. A
GETschema read through$fetchhas to coerce strings, and the types cannot check that. - A call with a type argument,
$fetch<Post>("/x"), is unchecked and its result isPost. useFetchanduseLazyFetchuse 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 call | JsonSerialized<T>: a route's JSON answer, $fetch, useFetch | |
|---|---|---|
Date, URL | kept | string |
undefined | kept | the property is dropped |
Map, Set, typed arrays, RegExp, bigint, Error | kept | refused |
a type with toJSON() | refused unless registered in app/transport.ts | the result of toJSON() |
a type registered in app/transport.ts | kept | refused unless it has toJSON() |
| functions, symbols, other class instances | NotSerializable<"data.save is a function"> | refused |
useData(),LoaderData<N>,Route.LoadData,useAsyncData(fn, input),useAction(fn).stateand the result of a handler stub areSerialized. A route's$fetchanduseFetchresult isJsonSerialized: aDateis astringthere, 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 startsnot serializable: data.post.save is a function. The error is where the leaf is used, and insilgi typecheck.satisfies Route.Loadcannot 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".
Search
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;
getof a key that was not set and has no default throwsRoute context "user" is not available. The default of the sample isnull.- It is not available while a data prefetch runs, and in a
loadthat is omitted from the payload (serialization: "never"), because middleware runs in neither. Reading it there throws… is not available while prefetchingor… while hydrating an omitted loader, whatever the default. A prefetch that throws is dropped, and the navigation runs theloadafter 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
useRouteContextfor components: the middleware does not run again in the browser for the first request, so a component would render something else there. Read what theloadreturned, or use state. - A key created twice with one name throws
Route context "user" is created twicein dev, when the two calls are in different files. - This is not the
ctx.contextof 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:
| File | Includes | types |
|---|---|---|
.silgi/tsconfig.app.json | app/, shared/, .silgi/types | bun |
.silgi/tsconfig.server.json | server/, shared/, .silgi/types-server | bun |
.silgi/tsconfig.shared.json | shared/, .silgi/types-shared | none |
.silgi/tsconfig.node.json | silgi.config.ts, app/app.config.ts and the type files of modules | bun |
.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
Buninshared/, 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, sodocumentinserver/compiles. #serveris 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 },
});
| Key | Default | |
|---|---|---|
typescript.strict | true | compilerOptions.strict in all five files |
typescript.tsConfig | an 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.typedPaths | true | false keeps link targets typed string |
build.typecheck | false | run 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-tscandsvelte-checkneed Node 24 or later onPATH. Bun'snodeshim is skipped, becausevue-tscunder it checks nothing and passes.--mode <name>loads the config of that mode, since a block may change a typed key.silgi build --typecheckandbuild.typecheck: truerun the same check after typegen and before bundling.- The starters'
typecheckscript issilgi typecheck.