DEV Community

Yury
Yury

Posted on

TypeScript-first job chains: end-to-end inference for background jobs

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[]
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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 };
  };
}>();
Enter fullscreen mode Exit fullscreen mode

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.
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. The typeName you pass must be one of the types declared in the current job's continueWith.
  2. The input must 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
      }),
    );
  },
},
Enter fullscreen mode Exit fullscreen mode

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" };
};
Enter fullscreen mode Exit fullscreen mode

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 },
  });
});
Enter fullscreen mode Exit fullscreen mode

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" };
};
Enter fullscreen mode Exit fullscreen mode

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" }[]];
};
Enter fullscreen mode Exit fullscreen mode

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
    }
    // ...
  },
};
Enter fullscreen mode Exit fullscreen mode

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" },
  ];
};
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)