Most job queues forget your types at the queue boundary
A Promise chain in TypeScript is a thing of beauty:
const summary = await fetchUser(id)
.then((user) => fetchOrders(user.id))
.then((orders) => summarize(orders));
// ^^^^^^ Order[]
Each step's input is typed by the previous step's output. Try to call fetchOrders(user.something_that_doesnt_exist) and the compiler stops you. Misuse the summary variable at the end and the compiler stops you.
Now the typical background-job version of the same thing:
await queue.add("fetch-user", { id });
// ... worker processes fetch-user, then enqueues fetch-orders ...
// ... worker processes fetch-orders, then enqueues summarize ...
The compiler sees string (the type name) and unknown (the payload). Whatever the fetch-user handler returned, nothing checks that the input to fetch-orders matches it. Renaming a field on the user payload silently breaks production.
This isn't a TypeScript limitation. It's a design choice in most job libraries: jobs are loose key-value messages, scheduled by name. The type system has nothing to anchor to.
I wanted job chains that worked more like Promise.then() — but persisted, retryable, and able to live across worker restarts. I wrote a library called Queuert that does this. Here's the type story.
Defining job types
You start by declaring the shape of every job type your application uses:
import { defineJobTypes } from "queuert";
const jobTypes = defineJobTypes<{
"fetch-user": {
entry: true;
input: { userId: number };
output: { id: number; email: string; name: string };
continueWith: { typeName: "fetch-orders" };
};
"fetch-orders": {
input: { userId: number };
output: { orders: { id: number; total: number }[] };
continueWith: { typeName: "summarize" };
};
"summarize": {
input: { orders: { id: number; total: number }[] };
output: { totalRevenue: number };
};
}>();
Each entry has:
-
input— what the job receives. -
output— what the handler returns. -
continueWith— which job types this one can chain into next (optional; terminal jobs omit it). -
entry: true— marks the type as one that can start a chain.
The whole declaration is a single TypeScript type literal. There's no codegen, no decorators, no separate schema language.
Type-checked chain creation
Starting a chain checks the entry type's input against the literal you pass:
client.startChain({
// ...
typeName: "fetch-user",
input: { userId: 42 },
// ^^^^^^ must match { userId: number }
});
client.startChain({
// ...
typeName: "summarize",
// ^^^^^^^^^^^ Type '"summarize"' is not assignable
// to type '"fetch-user"'
// — only `entry: true` types are allowed at the start of a chain.
});
You can't start a chain at a non-entry type. You can't start it with the wrong input. Both errors are caught at compile time, before the job hits your queue table.
Type-checked continuations
Inside a handler, you call continueWith to chain into the next job. The library checks two things:
- The
typeNameyou pass must be one of the types declared in the current job'scontinueWith. - The
inputmust match the next type's declared input.
"fetch-user": {
attemptHandler: async ({ job, complete }) => {
const user = await fetchUserFromDb(job.input.userId);
return complete(async ({ continueWith }) =>
continueWith({
typeName: "fetch-orders",
// ^^^^^^^^^^^^ must be in fetch-user's `continueWith` declaration
input: { userId: user.id },
// ^^^^^^ must match fetch-orders' declared input
}),
);
},
},
Try to chain to a job type the current type didn't declare? Compile error. Pass the wrong input shape? Compile error.
This is the part that feels like Promise chains. The output of one job flows into the input of the next, type-checked at every step.
Branching
A chain can branch by listing multiple types in continueWith:
"trial-decision": {
input: { subscriptionId: number };
continueWith: { typeName: "convert-to-paid" | "expire-trial" };
};
In the handler, the compiler accepts either branch and forces you to pass the matching input for whichever you pick:
return complete(async ({ continueWith }) => {
if (shouldConvert) {
return continueWith({
typeName: "convert-to-paid",
input: { subscriptionId: job.input.subscriptionId },
});
}
return continueWith({
typeName: "expire-trial",
input: { subscriptionId: job.input.subscriptionId },
});
});
Each branch is type-checked independently against its target type's input.
Loops
A job can chain back to itself:
"charge-billing": {
input: { subscriptionId: number; cycle: number };
output: { finalCycle: number; totalCharged: number };
continueWith: { typeName: "charge-billing" | "cancel-subscription" };
};
This is just a self-reference in the type system. The compiler resolves it cleanly. You can build retry loops, polling loops, recurring billing — anything where a job re-schedules itself based on its output and decides when to terminate.
Fan-in: typed blockers
Sometimes a job needs to wait for several other chains to complete before running. In Queuert that's a blockers declaration.
Variable-count blockers (e.g. "wait for any number of fetch-source chains"):
"aggregate-data": {
entry: true;
input: { reportId: string };
output: { reportId: string; totalSources: number };
blockers: [...{ typeName: "fetch-source" }[]];
};
Inside the handler, job.blockers is a typed array — each entry is a completed fetch-source job, with its output already inferred:
"aggregate-data": {
attemptHandler: async ({ job, complete }) => {
for (const blocker of job.blockers) {
console.log(blocker.output.sourceId, blocker.output.data);
// ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
// from fetch-source's declared output
}
// ...
},
};
Fixed-slot blockers (e.g. exactly one validate-user and one load-config) are also typed at the right index:
"perform-action": {
blockers: [
{ typeName: "validate-user" },
{ typeName: "load-config" },
];
};
Now job.blockers[0].output is the validate-user output and job.blockers[1].output is the load-config output, with each type inferred at the right index.
What this prevents
A non-exhaustive list of bugs the compiler refuses to let you ship:
- Renaming a field on a job's output without updating downstream handlers.
- Chaining to a non-entry type as if it were an entry.
- Chaining to a type that wasn't declared in
continueWith. - Passing the wrong shape to a chain's input.
- Reading a field off a blocker's output that doesn't exist.
- Treating a fixed-slot blocker like an array of one type when it actually has heterogeneous slots.
In a typical Redis-backed queue, all of these become runtime errors — usually the kind that show up at 3am in your error tracker.
What it costs
Type-machinery this dense isn't free. Queuert publishes a type complexity benchmark: linear chains up to 100 types compile in ~1s, branched/blocker/loop graphs of similar size stay around the same, and merging 500 types from independent slices stays under 2.5s. There's a ceiling, but it's well above realistic application complexity.
The library also internally caches navigation maps and uses tail-recursive type forms to keep instantiation counts down — a 0.5.0 rewrite cut blocker-heavy navigation by ~86%. If you ever hit a ceiling, it's a tractable problem.
Try it
Queuert is MIT-licensed and pre-1.0. There's a companion post on the architectural side — why your database should be the source of truth for job state — that pairs with this one.
- Repo: github.com/kvet/queuert
- Docs: kvet.github.io/queuert
Top comments (0)