Skip to content
fossyl

Type-Safe Routes

Fossyl’s type system is designed to give you compile-time guarantees about your API routes. Every URL parameter, query string, request body, and authentication context is fully typed — no explicit generics required.

Route paths use :param syntax for dynamic segments. Fossyl extracts these parameters at the type level and exposes them as typed fields on the url object.

export const getUserRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean } = routerRouter<"/api/users">
.createEndpoint<Path extends `/api/users${string}`>( path: Path, ) => Endpoint<Path, true>("/api/users/:id")
.get<Response extends ResponseData>( handler: ( parameters: { url: { id: string } & { readonly __kind: "url" } } & { readonly __kind: "parameters" }, ) => () => Promise<Response>, ) => Route((params{ url: { id: string } & { readonly __kind: "url" } } & { readonly __kind: "parameters" }) => async () => {
const userUserRow = { id: number name: string email: string created_at: string } = await userService.getUserRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean }(Number(params{ url: { id: string } & { readonly __kind: "url" } } & { readonly __kind: "parameters" }.url{ id: string } & { readonly __kind: "url" }.idstring));
return { typeName: "User" as const, ...userUserRow = { id: number name: string email: string created_at: string } };
});

The Params<Path> utility type powers this — it parses the path template at the type level and produces an object type like { id: string } or { postId: string; commentId: string }.

Every endpoint starts as a builder. Chain middleware methods — .authenticator(), .validator(), .query(), .paginate() — to configure the handler, then call an HTTP method to produce a Route. The type system exposes only valid transitions at each step.

For public GET/DELETE endpoints. The handler receives URL params and optionally parsed query params.

export const getUserRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean } = routerRouter<"/api/users">
.createEndpoint<Path extends `/api/users${string}`>( path: Path, ) => Endpoint<Path, true>("/api/users/:id")
.get<Response extends ResponseData>( handler: ( parameters: { url: { id: string } & { readonly __kind: "url" } } & { readonly __kind: "parameters" }, ) => () => Promise<Response>, ) => Route((params{ url: { id: string } & { readonly __kind: "url" } } & { readonly __kind: "parameters" }) => async () => {
const userUserRow = { id: number name: string email: string created_at: string } = await userService.getUserRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean }(Number(params{ url: { id: string } & { readonly __kind: "url" } } & { readonly __kind: "parameters" }.url{ id: string } & { readonly __kind: "url" }.idstring));
return { typeName: "User" as const, ...userUserRow = { id: number name: string email: string created_at: string } };
});

For POST/PUT endpoints where anyone can submit data, but the body must be validated.

export const createUserRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean } = routerRouter<"/api/users">
.createEndpoint<Path extends `/api/users${string}`>( path: Path, ) => Endpoint<Path, true>("/api/users")
.authenticator( headers: Record<string, string>, ) => Promise<{ userId: string } & Authentication>(authenticator( headers: Record<string, string>, ) => Promise<{ userId: string } & Authentication>)
.validator<RequestBody extends unknown>( validatorFunction: ValidatorFunction<RequestBody>, ) => { post: <Response extends ResponseData>( handler: ( auth: { userId: string } & Authentication & { readonly __kind: "auth" }, ) => undefined extends RequestBody ? () => Promise<Response> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<Response>, ) => Route put: <Response extends ResponseData>( handler: ( auth: { userId: string } & Authentication & { readonly __kind: "auth" }, ) => undefined extends RequestBody ? () => Promise<Response> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<Response>, ) => Route }(createUserValidator( data: unknown, params?: util.InexactPartial<ParseParams>, ) => { name: string; email: string })
.post<Response extends ResponseData>( handler: ( auth: { userId: string } & Authentication & { readonly __kind: "auth" }, ) => ( body: { name: string; email: string } & { readonly __kind: "body" }, ) => () => Promise<Response>, ) => Route((_auth{ userId: string } & Authentication & { readonly __kind: "auth" }) => (body{ name: string; email: string } & { readonly __kind: "body" }) => async () => {
const userUserRow = { id: number name: string email: string created_at: string } = await userService.createUserRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean }(body{ name: string; email: string } & { readonly __kind: "body" }.namestring, body{ name: string; email: string } & { readonly __kind: "body" }.emailstring);
return { typeName: "User" as const, ...userUserRow = { id: number name: string email: string created_at: string } };
});

The validator function’s return type becomes the body type in the handler — inference flows through automatically.

Authenticated — Auth, No Body Validation

Section titled “Authenticated — Auth, No Body Validation”

For GET/DELETE endpoints that require an authenticated user.

export const getTodoRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean } = routerRouter<"/api/todos">
.createEndpoint<Path extends `/api/todos${string}`>( path: Path, ) => Endpoint<Path, true>("/api/todos/:id")
.authenticator( headers: Record<string, string>, ) => Promise<{ userId: string } & Authentication>(authenticator( headers: Record<string, string>, ) => Promise<{ userId: string } & Authentication>)
.get<Response extends ResponseData>( handler: ( parameters: { query: { q: string; limit?: number; offset?: number } & { readonly __kind: "query" } } & { readonly __kind: "parameters" }, ) => () => Promise<Response>, ) => Route((paramsRecord<string, string | undefined>) => (_auth{ userId: string } & Authentication & { readonly __kind: "auth" }) => async () => {
const todoTodoRow = { id: number title: string completed: number created_at: string } = await todoService.getTodoRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean }(Number(paramsRecord<string, string | undefined>.url{ id: string } & { readonly __kind: "url" }.idnumber));
return { typeName: "Todo" as const, ...todoTodoRow = { id: number title: string completed: number created_at: string } };
});

For POST/PUT endpoints that need both authentication and a validated body.

export const updateTodoRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean } = routerRouter<"/api/todos">
.createEndpoint<Path extends `/api/todos${string}`>( path: Path, ) => Endpoint<Path, true>("/api/todos/:id")
.authenticator( headers: Record<string, string>, ) => Promise<{ userId: string } & Authentication>(authenticator( headers: Record<string, string>, ) => Promise<{ userId: string } & Authentication>)
.validator<RequestBody extends unknown>( validatorFunction: ValidatorFunction<RequestBody>, ) => { post: <Response extends ResponseData>( handler: ( auth: { userId: string } & Authentication & { readonly __kind: "auth" }, ) => undefined extends RequestBody ? () => Promise<Response> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<Response>, ) => Route put: <Response extends ResponseData>( handler: ( auth: { userId: string } & Authentication & { readonly __kind: "auth" }, ) => undefined extends RequestBody ? () => Promise<Response> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<Response>, ) => Route }(updateTodoValidator( data: unknown, params?: util.InexactPartial<ParseParams>, ) => { title?: string | undefined; completed?: boolean | undefined })
.put<Response extends ResponseData>( handler: ( parameters: { url: { id: string } & { readonly __kind: "url" } } & { readonly __kind: "parameters" }, ) => ( auth: { userId: string } & Authentication & { readonly __kind: "auth" }, ) => ( body: { title?: string | undefined; completed?: boolean | undefined } & { readonly __kind: "body" }, ) => () => Promise<Response>, ) => Route((paramsRecord<string, string | undefined>) => (_auth{ userId: string } & Authentication & { readonly __kind: "auth" }) => (body{ title: string } & { readonly __kind: "body" }) => async () => {
const todoTodoRow = { id: number title: string completed: number created_at: string } = await todoService.updateTodoRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean }(Number(paramsRecord<string, string | undefined>.url{ id: string } & { readonly __kind: "url" }.idnumber), body{ title: string } & { readonly __kind: "body" });
return { typeName: "Todo" as const, ...todoTodoRow = { id: number title: string completed: number created_at: string } };
});

For collection endpoints that return paginated results. Use .paginate(config) on the endpoint, then chain .get().

export const listTodosRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean } = routerRouter<"/api/todos">
.createEndpoint<Path extends `/api/todos${string}`>( path: Path, ) => Endpoint<Path, true>("/api/todos")
.paginate( paginationConfig: PaginationConfig, ) => { validator: <RequestBody extends unknown>( validatorFunction: ValidatorFunction<RequestBody>, ) => { post: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => undefined extends RequestBody ? () => Promise<PaginatedResponse<Response>> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route put: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => undefined extends RequestBody ? () => Promise<PaginatedResponse<Response>> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route } authenticator: <Auth extends Authentication>( authenticationFunction: AuthenticationFunction<Auth>, ) => { validator: <RequestBody extends unknown>( validatorFunction: ValidatorFunction<RequestBody>, ) => { post: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => undefined extends Auth ? undefined extends RequestBody ? () => Promise<PaginatedResponse<Response>> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<PaginatedResponse<Response>> : ( auth: Auth & { readonly __kind: "auth" }, ) => undefined extends RequestBody ? () => Promise<PaginatedResponse<Response>> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route put: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => undefined extends Auth ? undefined extends RequestBody ? () => Promise<PaginatedResponse<Response>> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<PaginatedResponse<Response>> : ( auth: Auth & { readonly __kind: "auth" }, ) => undefined extends RequestBody ? () => Promise<PaginatedResponse<Response>> : ( body: RequestBody & { readonly __kind: "body" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route } } & { get: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => undefined extends Auth ? () => Promise<PaginatedResponse<Response>> : ( auth: Auth & { readonly __kind: "auth" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route post: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => undefined extends Auth ? () => Promise<PaginatedResponse<Response>> : ( auth: Auth & { readonly __kind: "auth" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route put: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => undefined extends Auth ? () => Promise<PaginatedResponse<Response>> : ( auth: Auth & { readonly __kind: "auth" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route delete: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => undefined extends Auth ? () => Promise<PaginatedResponse<Response>> : ( auth: Auth & { readonly __kind: "auth" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route } } & { get: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route post: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route put: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route delete: <Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route }({ defaultPageSize: 20, maxPageSize: 100 })
.get<Response extends ResponseData>( handler: ( parameters: { query: { q: string; limit?: number; offset?: number } & { readonly __kind: "query" } } & { readonly __kind: "parameters" }, ) => () => Promise<Response>, ) => Route((paramsRecord<string, string | undefined>) => async (): Promise<PaginatedResponse<{ typeName"Todo": "Todo"; idnumber: number }>> => {
const result{ data: TodoRow[]; hasMore: boolean; total: number } = await todoService.listTodosRoute = { path: string method: RestMethod steps: Steps[] handler: Function authenticator?: AuthenticationFunction<any> validator?: ValidatorFunction<any> queryValidator?: ValidatorFunction<any> urlParamValidator?: ValidatorFunction<any> paginationConfig?: PaginationConfig hasTransaction: boolean }(paramsRecord<string, string | undefined>.pagination{ page: number; pageSize: number; hasMore: boolean; total: number });
return {
data: result{ data: TodoRow[]; hasMore: boolean; total: number }.data{ typeName: "Todo"; id: number }[].map<U>( callbackfn: (value: TodoRow, index: number, array: TodoRow[]) => U, thisArg?: any, ) => U[]((tTodoRow = { id: number title: string completed: number created_at: string }) => ({ typeName: "Todo" as const, id: tTodoRow = { id: number title: string completed: number created_at: string }.idnumber })),
pagination: {
page: paramsRecord<string, string | undefined>.pagination{ page: number; pageSize: number; hasMore: boolean; total: number }.pagenumber,
pageSize: paramsRecord<string, string | undefined>.pagination{ page: number; pageSize: number; hasMore: boolean; total: number }.pageSizenumber,
hasMore: result{ data: TodoRow[]; hasMore: boolean; total: number }.hasMoreboolean,
total: result{ data: TodoRow[]; hasMore: boolean; total: number }.totalnumber,
},
};
});

Paginated routes return PaginatedResponse<T> — a { data: T[], pagination: { page, pageSize, hasMore?, total? } } shape.

All builder configurations produce the same Route object at runtime — it’s a plain { path, method, steps, handler, ... } struct that adapters consume. The strong dependent typing lives entirely in the builder chain (.createEndpoint().authenticator().validator().get()), not in the Route type itself. This means the type of getTodo, createUser, listTodos, etc. is all just Route — the type safety is guaranteed at construction time, not inspection time.

Fossyl uses a few key techniques to drive type inference without explicit generics:

Each step in the builder chain returns a distinct type that only exposes valid transitions. TypeScript enforces the FSM at compile time:

  • Neither .authenticator() nor .validator() → handler receives params only. Available on OpenRouter (all HTTP methods).
  • .authenticator() only → handler receives (params) => (auth) => () => Res. Available on AuthenticatedRouter (all HTTP methods).
  • .validator() only → handler receives (params) => (body) => () => Res. Available on ValidatedRouter (POST/PUT only).
  • .authenticator() + .validator() → handler receives (params) => (auth) => (body) => () => Res. Available on FullRouter (POST/PUT only).
  • .paginate() → handler receives params with pagination, returns PaginatedResponse<T>.

QueryableRouter and PaginatedRouter both inherit the OpenRouter interface, so all the above transitions apply to them as well.

The Authentication and RequestBody types use unique symbols as brands:

declare const authBrand: unique symbol;
type Authentication = { readonly [authBrand]: "Auth" };
declare const requestBrand: unique symbol;
type RequestBody = { readonly [requestBrand]: "RequestBody" };

authWrapper() applies the Authentication brand at runtime. The brands are what let the type system distinguish between “data from the authenticator” and “data from the validator” — and pass them to the correct handler parameter positions.

All route handlers must return objects that include a typeName property:

type ResponseData<TypeName extends string = string> = {
typeNameTypeName: TypeName;
};

This enables self-describing API responses. The ApiResponse wrapper automatically includes the typeName as a type field on the outer envelope:

typeT["typeName"] ApiResponse<T extends ResponseData> = {
success"true": "true";
typeT["typeName"]: T["typeName"];
dataT: T;
};

The Express adapter (and others) wrap all responses in this format automatically.

Fossyl leaves error handling to you. Throw from authenticator, validator, or handler and handle it in your adapter’s error middleware. This keeps the type system focused on what matters: correct data flow at compile time.