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
Section titled “URL Parameters”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.
Path Pattern Rules
Section titled “Path Pattern Rules”:id→{ id: string }:postId/comments/:commentId→{ postId: string; commentId: string }- Static segments and dynamic params can be mixed freely
Query Parameters
Section titled “Query Parameters”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, }; });Query Validation with Zod
Section titled “Query Validation with Zod”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, }; });Query vs URL Params
Section titled “Query vs URL Params”| Aspect | URL Params | Query Params |
|---|---|---|
| Syntax | :id in path | ?key=value in URL |
| Required | Always | Optional |
| Type | Always string | Any (coerced) |
| Validation | Automatic | Via queryValidator |
| Access | url.paramName | query.fieldName |
Pagination Parameters
Section titled “Pagination Parameters”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, }, }; });PaginationConfig
Section titled “PaginationConfig”type PaginationConfig = { defaultPageSizenumber: number; // Default when ?pageSize is omitted maxPageSizenumber: number; // Hard upper limit enforced at runtime};Response Shape
Section titled “Response Shape”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.