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.

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 } };
});

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.

export const searchTodosRoute = { 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/search")
.query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }((data{ typeName: "Todo"; id: number }[]): { qstring: string; limitnumber | undefined?: number; offsetnumber | undefined?: number } => {
const paramsRecord<string, string | undefined> = data{ typeName: "Todo"; id: number }[] 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 () => {
return {
typeName: "SearchResult" as const,
q: query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.qstring,
limit: query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.limitnumber | undefined,
offset: query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.offsetnumber | undefined,
};
});

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

export const searchTodosRoute = { 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/search")
.query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }((data{ typeName: "Todo"; id: number }[]): { qstring: string; limitnumber | undefined?: number; offsetnumber | undefined?: number } => {
const paramsRecord<string, string | undefined> = data{ typeName: "Todo"; id: number }[] 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 () => {
return {
typeName: "SearchResult" as const,
q: query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.qstring,
limit: query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.limitnumber | undefined,
offset: query{ q: string; limit?: number; offset?: number } & { readonly __kind: "query" }.offsetnumber | undefined,
};
});
AspectURL ParamsQuery Params
Syntax:id in path?key=value in URL
RequiredAlwaysOptional
TypeAlways stringAny (coerced)
ValidationAutomaticVia queryValidator
Accessurl.paramNamequery.fieldName

Paginated routes handle pagination automatically. Call .paginate(config) on the endpoint to configure defaults and limits.

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,
},
};
});
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.