Type-safe query keys for TypeScript: describe your keys once as a tree, then read every key from it, with exact types. Made with TanStack Query in mind, and works with any library that uses array keys.
import { keycraft } from "@kellanjs/keycraft";
export const keys = keycraft({
users: {
list: null,
byId: {
$scope: (id: string) => id,
posts: null,
},
},
});
keys.users.list.$key; // ["users", "list"]
keys.users.byId("42").$key; // ["users", "byId", "42"]
keys.users.byId("42").posts.$key; // ["users", "byId", "42", "posts"]Query libraries identify cached data by a key: an array like
["users", "42", "posts"]. Written by hand all over an app, keys drift apart:
useQuery({ queryKey: ["users", userId, "posts"], queryFn: fetchPosts });
// Elsewhere, after an update
queryClient.invalidateQueries({ queryKey: ["user", userId] }); // typoNothing fails. The typo just refreshes nothing, and the user sees stale data. Refreshing "everything about this user" also relies on every one of those keys starting the same way, which nothing enforces.
- One definition for every key. Keys are read from the tree
(
keys.users.byId(id).posts.$key), so they're spelled the same everywhere, and a typo is a compile error. - Keys nest. A key always starts with its parent's key, so invalidating a parent refreshes everything below it.
- Exact types.
keys.users.byId("42").posts.$keyis typedreadonly ["users", "byId", string, "posts"], and TanStack Query passes that type on to your query function. - Mistakes in the definition are compile errors, reported where the mistake is.
It has no dependencies, and no ties to any one query library: a key is a plain array.
npm install @kellanjs/keycraft- TypeScript 5.0 or newer.
strictmode is recommended, but not required (see Good to know). - An ES2020 runtime: Node 18+, Chrome 80+, Firefox 74+, Safari 13.1+.
- ES modules. The package has no CommonJS build.
Each property becomes a node, and $key is the path to it. A node with
$scope takes arguments, which become part of the key.
import { keycraft } from "@kellanjs/keycraft";
export const keys = keycraft({
users: {
list: null,
byId: {
$scope: (id: string) => id,
profile: null,
posts: null,
},
},
});import { useQuery } from "@tanstack/react-query";
function UserPosts({ userId }: { userId: string }) {
const posts = useQuery({
queryKey: keys.users.byId(userId).posts.$key,
queryFn: () => fetchPosts(userId),
});
// ...
}Use a shorter key to refresh everything below it:
// This user's profile and posts
queryClient.invalidateQueries({ queryKey: keys.users.byId(userId).$key });
// Every user's profile and posts (but not the list)
queryClient.invalidateQueries({ queryKey: keys.users.byId.$key });
// Everything about users, the list included
queryClient.invalidateQueries({ queryKey: keys.users.$key });Each level adds one element to the key: the property's name, or, when you call
a scoped node, whatever its $scope returns.
| Expression | Key |
|---|---|
keys.users.$key |
["users"] |
keys.users.list.$key |
["users", "list"] |
keys.users.byId.$key |
["users", "byId"] |
keys.users.byId("42").$key |
["users", "byId", "42"] |
keys.users.byId("42").posts.$key |
["users", "byId", "42", "posts"] |
So every key starts with its parent's key. That's what makes invalidating by prefix work: query libraries like TanStack Query treat a key as matching every key that starts with it.
A node with no children is written null or {}; the two mean the same.
const keys = keycraft({
settings: null,
users: {
list: {},
},
});
keys.settings.$key; // ["settings"]
keys.users.list.$key; // ["users", "list"]$scope turns a node into a function. Calling it runs the scope with your
arguments, and adds what it returns to the key, as one element:
interface Filters {
status: "open" | "done";
page: number;
}
const keys = keycraft({
todos: {
byId: { $scope: (id: number) => id },
list: { $scope: (filters: Filters) => filters },
between: {
$scope: (from: Date, to: Date) => [from.toISOString(), to.toISOString()],
},
},
});
keys.todos.byId(7).$key; // ["todos", "byId", 7]
keys.todos.list({ status: "open", page: 1 }).$key;
// ["todos", "list", { status: "open", page: 1 }]
keys.todos.between(new Date("2026-01-01"), new Date("2026-01-31")).$key;
// ["todos", "between", ["2026-01-01T00:00:00.000Z", "2026-01-31T00:00:00.000Z"]]The scope can have any parameters, and its parameter names show up when you
call the node. It must return plain data: strings, numbers, booleans, null,
undefined, and arrays and plain objects of those. Returning an object is
fine: TanStack Query compares objects by their contents, whatever order their
properties are in.
Anything else is a compile error, because query libraries can't compare it
reliably. That includes functions, a Map, a bigint, and dates: a Date
has no properties of its own, so TanStack Query's prefix matching would treat
every date as equal. Return date.toISOString() or date.getTime() instead,
as above.
Return plain objects rather than class instances. Query libraries compare keys by their own enumerable properties, so an instance that keeps its data in private fields or getters looks empty, and two different instances become the same key. The type check can't see private fields, so this one is up to you.
For parameters that can take different forms, use a union of parameter lists:
(...args: [id: string] | [first: string, last: string]) => args. (An
overloaded function also works, but TypeScript only keeps its last overload.)
A scoped node's children are only on what the call returns, so you can't forget the arguments:
const keys = keycraft({
users: {
byId: { $scope: (id: string) => id, posts: null },
},
});
keys.users.byId("42").posts.$key; // ["users", "byId", "42", "posts"]
// @ts-expect-error: forgot the id
keys.users.byId.posts;keys.users.byId.$key (without calling it) is still there: it's the prefix of
every user's key, for invalidating them all.
A scope may have optional parameters. Whatever it returns goes in the key,
undefined included, so page() and page(1) are different keys:
const keys = keycraft({
todos: {
page: { $scope: (page?: number) => page },
},
});
keys.todos.page().$key; // ["todos", "page", undefined]
keys.todos.page(1).$key; // ["todos", "page", 1]If no argument should mean page 1, say so in the scope:
(page?: number) => page ?? 1.
One caution: query libraries don't always tell undefined and null apart.
TanStack Query, for example, treats them as the same key when it looks a query
up, but as different keys when matching by prefix. So don't let a scope return
both and rely on them being different.
Because keys match by prefix, a node that's used as a query and has
children also matches those children. Here, the notebook's notes list is
byNotebook(id) itself, and its search results sit below it:
const keys = keycraft({
notes: {
byNotebook: {
$scope: (id: string) => id,
search: { $scope: (query: string) => query },
},
},
});
// Meant for the list, but also cancels any search for that notebook
queryClient.cancelQueries({ queryKey: keys.notes.byNotebook(id).$key });You can pass exact: true every time, but it's easier to give each query its
own leaf, and use the nodes above them only as prefixes:
const keys = keycraft({
notes: {
byNotebook: {
$scope: (id: string) => id,
list: null, // the notes list
search: { $scope: (query: string) => query },
},
},
});
// Only the list
queryClient.cancelQueries({ queryKey: keys.notes.byNotebook(id).list.$key });
// The list and every search, on purpose
queryClient.invalidateQueries({ queryKey: keys.notes.byNotebook(id).$key });segment() checks part of a definition on its own and keeps its exact type,
so it can live in its own file or be used in several places:
import { keycraft, segment } from "@kellanjs/keycraft";
const paginated = segment({
page: { $scope: (page: number) => page },
});
const keys = keycraft({
users: paginated,
posts: { drafts: paginated },
});
keys.users.page(2).$key; // ["users", "page", 2]
keys.posts.drafts.page(1).$key; // ["posts", "drafts", "page", 1]TanStack Query passes the key to your query function. Since keycraft's keys have exact types, you can read arguments straight out of the key:
const user = useQuery({
queryKey: keys.users.byId(userId).profile.$key,
queryFn: ({ queryKey: [, , id] }) => fetchProfile(id), // id: string
});keycraft doesn't depend on TanStack Query: $key is a plain array, so it works
anywhere an array key does, such as SWR, Pinia Colada, or TanStack Query's
Vue, Solid and Svelte versions. Invalidating by prefix depends on the library
matching keys by prefix, as TanStack Query does.
Mostly, typeof is all you need: typeof keys for the tree, and
(typeof node)["$key"] for one node's key. For functions that work with any
key or node, there are two general types:
import type { KeyNode, KeycraftKey } from "@kellanjs/keycraft";
function logKey(node: KeyNode) {
console.log(node.$key);
}
logKey(keys.users.byId("42"));
const recent: KeycraftKey[] = [keys.users.list.$key];keycraft checks the definition and reports each mistake where it is, while the rest of the tree stays typed:
const keys = keycraft({
users: {
// @ts-expect-error: Key 'users.byId' is a function: to make a key that
// takes arguments, use { $scope: ... }
byId: (id: string) => id,
// @ts-expect-error: Key 'users.$scoep' can't start with '$': that prefix is
// reserved for keycraft's own properties, like $scope
$scoep: (id: string) => id,
},
});It also catches keys that aren't null or an object, and scopes that return
something that can't go in a key. The same checks run when keycraft() is
called, so JavaScript code gets a TypeError that names the key.
Builds a key tree. definition is an object whose properties are:
nullor{}: a node with no children- an object: a node with a child for each property
- an object with
$scope: (...args) => value: a node that's called with the scope's arguments. The call returns a node whose key ends withvalue, and which has the other properties as children.
Keys can't start with $, except $scope.
Returns definition unchanged, after checking it like keycraft() does (at
compile time only). Use it for parts of a definition that you define
separately.
node.$key: the node's key, a frozen array.node(...args), for a node with$scope: a new node, whose key adds the scope's return value.
| Type | Description |
|---|---|
KeycraftKey |
Any key: readonly unknown[] |
KeyNode |
Any node: { readonly $key: KeycraftKey } |
KeycraftKeys<T> |
The tree that keycraft() returns for the definition T |
- Nodes can't be changed, and neither can their keys. Copy a key if you
need to extend it:
[...keys.users.$key, "extra"]. $keyisn't enumerable, soObject.keys(node)lists only its children.- A node turns into its key in a string, e.g.
${keys.users.list}is["users","list"], which helps in log messages. - Calling a scoped node builds a new node each time, including its children. That's cheap, but the result isn't cached.
- Without
strictNullChecks, TypeScript typesnullasany. Leaves still work, but a$-prefixed key whose value isnullisn't caught until runtime. WithnoImplicitAnyon, TypeScript may also reportnullleaves as implicitlyany; write them as{}instead.
npm install
npm run cinpm run ci lints, type-checks, builds, checks formatting and the package's
exports, and runs the tests:
tests/keycraft.test.ts: the runtimetests/keycraft.test-d.ts: types (checked bytsc, not run)tests/tanstack.test.ts: the keys in a real TanStack Query cachetests/package-consumer.test.ts: the built package, imported by nametests/declaration-emit.test.ts: exporting trees and nodes from a project that emits.d.tsfilestests/error-messages.test.ts: the wording of compile-time errors
If you made it this far, thanks for checking out the library, and I hope you find it useful in your projects!
Keycraft is open source under the terms of the MIT license.