Typed params from your API
Validate your filter config against your list endpoint's params type.
By default, params is inferred from your config. Each filter's value type
flows through automatically. That's the right choice most of the time.
When you have a typed API client, you can instead pass the endpoint's params
type as a type argument. You get key autocomplete, compile-time checking, and a
params typed exactly as your API type, directly and soundly assignable
to it:
interface ProductListParams {
status: 'open' | 'closed' | null; // required + nullable
sort: 'date' | 'price'; // required + non-null
search?: string | null; // optional + nullable
page: number; // pagination keys are owned by the hook —
per_page: number; // exclude them from what you declare filters for
}
const { params } = useFilters<ProductListParams>({
status: f.select({ label: 'Status', valueType: 'string', options: statusOptions }),
sort: f.select({
label: 'Sort',
valueType: 'string',
options: sortOptions,
defaultValue: 'date'
}),
search: f.text({ label: 'Search' })
});
productApi.getAll(params); // ✓ compiles, and every claim the type makes is trueThe contract
<P> isn't just a lookup table for value types. Its shape states
obligations, and the compiler enforces them:
| your param | obligation |
|---|---|
sort: X (required) | a filter for sort must be declared |
sort?: X (optional) | the filter may be omitted (key absent from params) |
status: X | null | defaultValue optional; unset resolves to null |
sort: X (no null) | defaultValue required; the value can't be null |
Together these make P's own shape hold at runtime, which is why params can
be typed as exactly P with no lies:
- a required key always has a declared filter, so it's always present;
- a non-null param always has a default, so an unset filter resolves to the
default, never
null; - a nullable (
| null) param is allowed to be unset; - an optional (
?:) key without a filter simply never appears.
Unset filters are null at runtime, never undefined. If your API type declares a param as
search?: string (no null), the hook could produce a value your type says is impossible. That's
why the contract demands a defaultValue there, or declare the param as string | null if
"unset" is a state your API accepts.
The compile errors read as instructions: a missing required key errors with
"property sort is missing", and a missing default errors with "property
defaultValue is missing" on that filter.
The same contract everywhere
<P> works identically on all three entry points, with the same obligations and the same
exact-P params:
// the hook
const { params } = useFilters<ProductListParams>(configs);
// the loader helper
const params = resolveFilterParams<ProductListParams>(configs, request.url);
// or bind once for both — checked once, aligned everywhere
const productFilters = defineFilters<ProductListParams>(configs);
productFilters.useFilters(); // params: ProductListParams
productFilters.resolveFilterParams(raw); // params: ProductListParamsFor a config shared between the hook and a loader,
defineFilters<P> is the natural home for the type
argument: one validation, guaranteed parity.
satisfies: validation with full inference
Passing <P> turns off per-config inference. TypeScript can't partially infer
type arguments, so params comes from P, not from your configs (literal
option types, for example, are widened to P's declarations). If you want the
checking and fully inferred params, validate the config with satisfies and
let the hook infer:
import type { FiltersFor } from '@mbsatimov/use-filters';
const configs = {
status: f.select({ label: 'Status', valueType: 'string', options: statusOptions }),
sort: f.select({ label: 'Sort', valueType: 'string', options: sortOptions, defaultValue: 'date' })
} satisfies FiltersFor<ProductListParams>;
const { params } = useFilters(configs);
// params.sort: 'date' | 'price' — inferred, non-null (it has a default),
// and the config was still checked against ProductListParams.Use whichever fits a given screen: <P> when you want params shaped as the
API contract, satisfies FiltersFor<P> when you want the contract checked but
the inferred (most precise) params.
Customized `createFilters`? Bind `FiltersFor` first
FiltersFor is a standalone type, so it cannot see your createFilters config: on its own it
assumes the built-in pagination keys (page / per_page) and arrayFormat: 'array'. If you
changed either, it checks against the wrong contract. Pass your values as the second and third
type arguments, and alias it once next to your factory:
// lib/filters.ts — next to your createFilters call
import type { FiltersFor as FiltersForBase } from '@mbsatimov/use-filters';
export type FiltersFor<P> = FiltersForBase<P, Record<'page' | 'page_size', number>, 'string'>;Then import FiltersFor from your own module. The factory-bound functions
(useFilters<P>, resolveFilterParams<P>, defineFilters<P>) already carry your config and need
no such binding.
Serialized array params
Under request.arrayFormat: 'string', array-shaped
filters (multiSelect, tags, ranges) arrive in params as a joined string, so
declare those params as string in your API type:
interface ProductListParams {
page: number;
per_page: number;
tags?: string | null; // f.multiSelect -> 'a,b'
}A factory-bound check knows this and accepts the multiSelect. A bare
satisfies FiltersFor<P> doesn't, and reports a type error that looks like a
library bug.