Skip to content

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:

typescript
export type User = {
    id: number,
    name: string,
    email: string,
    age: number,
    realm: { id: string, name: string },
};

The three layers

typescript
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 ? … : undefined contributes no condition for an empty box, so there is no if-shuffling around the query object.
  • Filters merge as an ordered logical AND: the user's name filter, the scope's realm.id condition and the age baseline all survive. Fields and sorts retain keyed left priority.
  • Everything is immutable: merging never mutates its inputs, so defaults is 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

vue
<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>
tsx
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

Released under the MIT License.