Skip to content
fossyl

Query Validation

Fossyl provides three levels of parameter validation: URL path parameters, query string parameters, and pagination parameters. Each is validated at a different point in the request lifecycle and has different type inference behavior.

URL parameters are declared using :param syntax in the route path. Fossyl parses them at the type level — no runtime configuration needed.

// URL params are declared with :param syntax in the path
// Fossyl extracts them at the type level automatically
import { createRouter<BasePath extends string>(_: BasePath) => Router<BasePath> } from "@fossyl/core";
const routerRouter<"/api"> = createRouter<BasePath extends string>(_: BasePath) => Router<BasePath><"/api">("/api");
const _getUserRouteRoute = { 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">
.createEndpoint<Path extends `/api${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(({ url{ id: string } & { readonly __kind: "url" } }) => async () => {
// url is typed as: { id: string }
const userIdstring = url{ id: string } & { readonly __kind: "url" }.idstring;
return { typeName: "User" as const, id: userIdstring };
});
// Multiple params work too
const _getCommentRouteRoute = { 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">
.createEndpoint<Path extends `/api${string}`>(path: Path) => Endpoint<Path, true>("/api/posts/:postId/comments/:commentId")
.get<Response extends ResponseData>( handler: ( parameters: { url: { id: string } & { readonly __kind: "url" } } & { readonly __kind: "parameters" }, ) => () => Promise<Response>, ) => Route(({ url{ id: string } & { readonly __kind: "url" } }) => async () => {
// url is typed as: { postId: string; commentId: string }
return {
typeName: "Comment" as const,
postId: url{ id: string } & { readonly __kind: "url" }.postIdstring,
commentId: url{ id: string } & { readonly __kind: "url" }.commentIdstring,
};
});

URL params are always string types since they come from the URL path. The Params<Path> utility type drives this inference.

  • :id{ id: string }
  • :postId/comments/:commentId{ postId: string; commentId: string }
  • Static segments and dynamic params can be mixed freely

Query parameters are optional and require a queryValidator. This keeps the type system honest — you explicitly declare what query params you expect.

// Query params are validated and typed via queryValidator
import { createRouter<BasePath extends string>(_: BasePath) => Router<BasePath> } from "@fossyl/core";
const routerRouter<"/api"> = createRouter<BasePath extends string>(_: BasePath) => Router<BasePath><"/api">("/api");
const _searchRouteRoute = { 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">
.createEndpoint<Path extends `/api${string}`>(path: Path) => Endpoint<Path, true>("/api/search")
.query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }((data): { qstring: string; limitnumber | undefined?: number; offsetnumber | undefined?: number } => {
const paramsRecord<string, string | undefined> = data as Record<string, string | undefinedundefined>;
if (!paramsRecord<string, string | undefined>.qstring || paramsRecord<string, string | undefined>.qstring.trim() => string() === "") {
throw new Error('Search query "q" is required');
}
return {
q: paramsRecord<string, string | undefined>.qstring,
limit: paramsRecord<string, string | undefined>.limitnumber | undefined ? Number(paramsRecord<string, string | undefined>.limitnumber | undefined) : undefinedundefined,
offset: paramsRecord<string, string | undefined>.offsetnumber | undefined ? Number(paramsRecord<string, string | undefined>.offsetnumber | undefined) : undefinedundefined,
};
})
.get<Response extends ResponseData>( handler: ( parameters: { query: { q: string; limit?: number; offset?: number } & { readonly __kind: "query" } } & { readonly __kind: "parameters" }, ) => () => Promise<Response>, ) => Route(({ query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" } }) => async () => {
// query is typed as: { q: string; limit?: number; offset?: number }
const results = await searchDatabase(query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.qstring, query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.limitnumber | undefined, query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.offsetnumber | undefined);
return { typeName: "SearchResult" as const, results };
});

The @fossyl/zod package provides zodQueryValidator for Zod-based query validation. Use z.coerce for type coercion from strings:

// Query validation with Zod — same pattern as body validation
import { z } from "zod";
import { zodQueryValidator<T extends ZodTypeAny>(schema: T) => T["parse"] } from "@fossyl/zod";
import { createRouter<BasePath extends string>(_: BasePath) => Router<BasePath> } from "@fossyl/core";
const routerRouter<"/api"> = createRouter<BasePath extends string>(_: BasePath) => Router<BasePath><"/api">("/api");
const searchSchema = z.object({
q: z.string().min(1, "Search query is required"),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
offset: z.coerce.number().int().min(0).optional().default(0),
});
const _searchRouteRoute = { 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">
.createEndpoint<Path extends `/api${string}`>(path: Path) => Endpoint<Path, true>("/api/search")
.query{ readonly __kind: "query" }(zodQueryValidator<T extends ZodTypeAny>(schema: T) => T["parse"](searchSchema))
.get<Response extends ResponseData>( handler: ( parameters: { query: { readonly __kind: "query" } } & { readonly __kind: "parameters" }, ) => () => Promise<Response>, ) => Route(({ query{ readonly __kind: "query" } }) => async () => {
// query is typed as: { q: string; limit: number; offset: number }
return { typeName: "SearchResult" as const, results: await searchDb(query{ readonly __kind: "query" }) };
});
AspectURL ParamsQuery Params
Syntax:id in path?key=value in URL
RequiredAlwaysOptional
TypeAlways stringAny (coerced)
ValidationAutomaticVia queryValidator
Accessurl.paramNamequery.fieldName

List routes handle pagination automatically. The route type provides page, pageSize, and configures defaults and limits.

// Paginated routes automatically handle pagination params:
// ?page=1&pageSize=20 → { page: 1, pageSize: 20 }
import { createRouter<BasePath extends string>(_: BasePath) => Router<BasePath> } from "@fossyl/core";
const routerRouter<"/api"> = createRouter<BasePath extends string>(_: BasePath) => Router<BasePath><"/api">("/api");
const _listPostsRouteRoute = { 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">
.createEndpoint<Path extends `/api${string}`>(path: Path) => Endpoint<Path, true>("/api/posts")
.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: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route(({ pagination{ page: number; pageSize: number; hasMore: boolean } }) => async () => {
// pagination is typed as: { page: number; pageSize: number }
const { pagenumber, pageSizenumber } = pagination{ page: number; pageSize: number; hasMore: boolean };
// N+1 trick: fetch one extra to determine hasMore
const items = await db
.selectFrom("posts")
.limit(pageSizenumber + 1)
.offset((pagenumber - 1) * pageSizenumber)
.execute();
const hasMoreboolean = items.length > pageSizenumber;
return {
data: hasMoreboolean ? items.slice(0, pageSizenumber) : items,
pagination: {
pagenumber,
pageSizenumber,
hasMoreboolean,
},
};
});
// Authenticated list routes work too
const _listMyPostsRouteRoute = { 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">
.createEndpoint<Path extends `/api${string}`>(path: Path) => Endpoint<Path, true>("/api/my-posts")
.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,
})
.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 }(myAuth)
.get<Response extends ResponseData>( handler: ( parameters: { pagination: PaginationParams & { readonly __kind: "pagination" } } & { readonly __kind: "parameters" }, ) => () => Promise<PaginatedResponse<Response>>, ) => Route(({ pagination{ page: number; pageSize: number; hasMore: boolean } }) => (authAuthentication & { readonly __kind: "auth" }) => async () => {
const items = await db
.selectFrom("posts")
.where("userId", "=", authAuthentication & { readonly __kind: "auth" }.userId)
.limit(pagination{ page: number; pageSize: number; hasMore: boolean }.pageSizenumber + 1)
.offset((pagination{ page: number; pageSize: number; hasMore: boolean }.pagenumber - 1) * pagination{ page: number; pageSize: number; hasMore: boolean }.pageSizenumber)
.execute();
return {
data: items.slice(0, pagination{ page: number; pageSize: number; hasMore: boolean }.pageSizenumber),
pagination: {
page: pagination{ page: number; pageSize: number; hasMore: boolean }.pagenumber,
pageSize: pagination{ page: number; pageSize: number; hasMore: boolean }.pageSizenumber,
hasMore: items.length > pagination{ page: number; pageSize: number; hasMore: boolean }.pageSizenumber,
},
};
});
type PaginationConfig = {
defaultPageSizenumber: number; // Default when ?pageSize is omitted
maxPageSizenumber: number; // Hard upper limit enforced at runtime
};

Paginated endpoints return a PaginatedResponse<T>:

type PaginatedResponse<T> = {
dataT: T[];
pagination{ page: number; pageSize: number; hasMore?: boolean; total?: number }: {
pagenumber: number;
pageSizenumber: number;
hasMoreboolean | undefined?: boolean; // Use N+1 trick to determine
totalnumber | undefined?: number; // Optional: include if you do a count query
};
};

The N+1 trick (fetching pageSize + 1 items) is the recommended way to determine hasMore without a separate count query.