Everyone loves type-safe APIs nowadays, but as far as I can tell query parameters are a blind spot to most people right now.
I create a hook which uses a builder pattern a while ago, which allows you to create type-safe query parameters. It is not very polished and contains some specific use cases, that's why I hesitated to share it. But as I successfully use it for a couple of month already and (afaik) no one came up with something similar so far, I wanted to share it.
How does it look like?
import * as pp from './page-params';
type FavoriteFood = 'pizza' | 'noodles' | 'wraps' | 'hot-dogs';
// imagine those queries control the state for a users overview
function useUsersParams() {
return pp.usePageParams(
pp
.configure()
.add(pp.start('start'))
.add(pp.number('pageSize').default(10))
.add(
pp
.boolean('onlyPremium')
.default(false)
.filter({ label: 'Only Premium' })
)
.add(pp.optionalNumber('age').default(18).filter({ label: 'Age' }))
.add(pp.param('name').filter({ label: 'Name' }))
.add(
pp
.list('favoriteFood')
.cast<FavoriteFood>()
.filter({ label: 'Favorite Food' })
)
);
}
const params = useUsersParams();
params.defs; // get defintions, e.g. to access defaults
params.defs.age.default; // === 18
params.value; // get all current values
params.value.age; // number | null
params.filterValues; // get all current filter values
params.filterValues.age; // number | null
params.filterLabels; // get all labels of the filter params
params.filterValues.age; // === 'Age'
params.hasActiveFilters; // returns true, if _any_ filter param is not default
params.resetFilters(); // resets filter params to default
params.set(); // allows you to set any param
params.setFilters(); // allows you to set any filter param
As you can see we had the use case of categorizing params: regular params and params used as filters. This allowed us to call things like resetFilters()
which will reset all params which were flagged as filter()
, but not the other params. Or: Whenever you run setFilters()
the param which was marked as start()
will be set to 0 automatically. (Which means: If you filter a collection and you reconfigure your filters, you'll jump back to the start.) I use the labels in several places: the actual filter form, as column headers or in error messages. It was just convenient to configure them in a single place.
If you don't need this, you can also fully ignore it and just don't call filter()
after your param definition. Of course you could also just come up with your own categories. E.g. I could imagine running state()
behind a param definition, which will mark it as a param which is stored in the History State, instead of the Query String in your URL.
Categories also help to create more sophisticated components. We have an <ActiveFilters/>
component in our source code, which maps filter params to different UIs. Thanks to generic types like FilterKeys
we can ensure that every filter has its own mapping and if at some point in time a new filter is added TypeScript will remember us that we might missed to add a new mapping.
If you have multiple filters which belong to different collections which can be individually filtered, sorted, paginated, etc. you can also group the params.
import * as pp from './page-params';
type UsersSortBy = 'age' | 'firstName' | 'lastNamee';
type PetsSortBy = 'age' | 'name';
type SortDirection = 'asc' | 'desc';
export function usePageParams() {
const users = pp.usePageParams(
pp
.configure()
.add(pp.start('start'))
.add(pp.param('sortBy').default<UsersSortBy>('firstName'))
.add(pp.param('sortDirection').default<SortDirection>('desc')),
'users'
);
const pets = pp.usePageParams(
pp
.configure()
.add(pp.start('start'))
.add(pp.param('sortBy').default<PetsSortBy>('name'))
.add(pp.param('sortDirection').default<SortDirection>('desc')),
'pets'
);
return {
users,
pets,
};
}
You can also use more complex data structures, if needed.
import * as pp from './page-params';
type User = { id: string; name: string };
export function usePageParams() {
return pp.usePageParams(
pp
.configure()
.add(
pp
.list('users')
.deserialize<User[]>(JSON.parse)
.serialize(JSON.stringify)
.filter({ label: 'Users' })
),
);
}
I hope this illustrates the idea behind this API. It's a bit verbose here and there and the implementation is tricky, but it really helped us to reduce a lot of error prone code by adding type-safety and some additional logic like "categories" or grouping.
PS: The builder pattern was needed, because I found no other way to get the same type-safety in TypeScript without it. If you know a better way, please let me know.