Type-Safe Frontend Queries
Chapter 2 of the recipes storyline: the client layer of the realm/user API, where queries are built, composed and encoded. Next: the server baseline in REST API with Express & TypeORM.
A list component typically owes its query to three sources at once: defaults it ships with, a scope its parent imposes via props, and user input from search/sort/pagination controls. This recipe composes the three declaratively: typed against the record, merged with explicit parameter rules, encoded on demand.
Works the same in Vue, React or plain TypeScript; rapiq has no framework dependency.
Shared types
Share the record types (and nothing else) between frontend and backend, via a types-only package or a generated client:
export type User = {
id: number,
name: string,
email: string,
age: number,
realm: { id: string, name: string },
};The three layers
import {
defineFilters, defineQuery, mergeQueries,
} from '@rapiq/core';
import { createURLCodec } from '@rapiq/codec-url';
import type { User } from 'my-api-types';
const codec = createURLCodec();
// 1. component defaults: shipped with the list. A filter baseline belongs
// here, where it merges in on every request.
const defaults = defineQuery<User>({
fields: ['id', 'name', 'email'],
filters: { age: { $gte: 18 } },
sorts: '-id',
pagination: { limit: 25 },
});
// 2. parent-imposed scope: realmId arrives as a prop / argument
// (fragments are plain values, so they travel well as data)
function scopeFor(realmId: string) {
return defineFilters<User>({ 'realm.id': realmId });
}
// 3. user input: from the search box & pager
function buildQuery(realmId: string, search: string, page: number) {
const userInput = defineQuery<User>({
// an empty search box contributes no condition; a filled one is
// and-ed alongside the baseline instead of replacing it.
filters: search ? { name: { $contains: search } } : undefined,
pagination: { offset: (page - 1) * 25 },
});
// keyed parameters use left priority; filters become an ordered AND
return mergeQueries(userInput, defineQuery<User>({ filters: scopeFor(realmId) }), defaults);
}
async function fetchUsers(realmId: string, search: string, page: number) {
const queryString = codec.encode(buildQuery(realmId, search, page));
const response = await fetch(`/users?${queryString}`);
return response.json();
}Three details doing quiet work here:
search ? … : undefinedcontributes no condition for an empty box, so there is noif-shuffling around the query object.- Filters merge as an ordered logical AND: the user's
namefilter, the scope'srealm.idcondition and theagebaseline all survive. Fields and sorts retain keyed left priority. - Everything is immutable: merging never mutates its inputs, so
defaultsis safe as a module constant and fragments like the realm scope are safe to pass around as props.
Once filters are part of a query, composition intentionally retains every predicate. When user input has to replace a default on the same field rather than narrow it, compose the input with mergeFiltersInput before calling defineQuery. It replaces per field, so a baseline like age >= 18 survives a search the user types.
Framework flavors
<script setup lang="ts">
const props = defineProps<{ realmId: string }>();
const search = ref('');
const page = ref(1);
const queryString = computed(() =>
codec.encode(buildQuery(props.realmId, search.value, page.value)));
watchEffect(async () => {
users.value = (await (await fetch(`/users?${queryString.value}`)).json()).data;
});
</script>function UserList({ realmId }: { realmId: string }) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const queryString = useMemo(
() => codec.encode(buildQuery(realmId, search, page)),
[realmId, search, page],
);
useEffect(() => {
fetch(`/users?${queryString}`)
.then((r) => r.json())
.then((json) => setUsers(json.data));
}, [queryString]);
// ...
}Because the encoded string is derived state, it also makes a perfect cache key for SWR/TanStack Query.
Guarding the wire
The default expression dialect carries nested or(...) trees and repeated-field conditions. Operators without a URL grammar still throw a typed error rather than sending something with different semantics. Legacy simple encoding is available only as an explicit migration option; see the URL codec reference.
Optionally, encode against the server's schema for early feedback; see schema-aware transport.
Next steps
- Merging & Composition: exact merge semantics.
- Building Queries: the full input grammar.