TypeScript types for a Sume job status: narrow on sume_status

Type the Sume job envelope as a discriminated union on sume_status, so a switch covers queued to canceled and the compiler flags a missed case. Runs on Node 22.

5 min readSume
All posts

To type a Sume job status response in TypeScript, model it as a discriminated union on sume_status: queued and processing are pending, completed is the only state with a result to fetch, and failed and canceled are terminal without one. A switch on that one field then lets the compiler prove you handled every state, and a sixth status added later turns into a compile error instead of a silent hang. The shapes below come from the envelope in Sume jobs and results.

This matters most for people porting code written against another vendor's queue API, because Sume returns two status fields on the status endpoint and they are easy to mix. Type one of them and never read the other. Teams that read status in one service and sume_status in another end up with a dashboard that disagrees with itself during an incident, which is the worst time to find out.

Which fields belong in the type?

Every submit and status response carries request_id (the job id), status_url, result_url, events_url, cancel_url, terminal, result_ready, and sume_status. Pending responses also carry next_poll_after_seconds. The status endpoint adds a queue-shaped status field (IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED, CANCELED) that maps one-to-one onto sume_status; the docs say the two always agree and also say not to mix them. Pick sume_status, since it uses the same words as the job list and the webhooks.

What does the union look like?

type Base = { request_id: string; status_url: string; result_url: string; events_url: string };
type Pending = Base & {
  sume_status: "queued" | "processing"; terminal: false;
  result_ready: false; next_poll_after_seconds?: number;
};
type Done = Base & { sume_status: "completed"; terminal: true; result_ready: boolean };
type Dead = Base & { sume_status: "failed" | "canceled"; terminal: true };
type Job = Pending | Done | Dead;

function next(job: Job): string {
  switch (job.sume_status) {
    case "queued":
    case "processing":
      return `wait ${job.next_poll_after_seconds ?? 5}s, then GET ${job.status_url}`;
    case "completed":
      return job.result_ready ? `GET ${job.result_url}` : "poll again";
    case "failed":
    case "canceled":
      return `GET /v1/jobs/{id} and read job.error`;
    default: {
      const unreachable: never = job;
      throw new Error(`unhandled ${unreachable}`);
    }
  }
}
const b = { request_id: "job_1", status_url: "s", result_url: "r", events_url: "e" };
console.log(next({ ...b, sume_status: "processing", terminal: false, result_ready: false }));
console.log(next({ ...b, sume_status: "completed", terminal: true, result_ready: true }));

Why keep terminal and result_ready apart?

Two details keep the union honest. First, terminal and result_ready are separate booleans. A job can be terminal and still have nothing to fetch, which is exactly the failed and canceled case, so do not collapse them into one flag. Second, a GET /result before completion returns 409 job_not_completed; with the union you never make that call from a pending branch, because the type does not offer you a result URL there in your own helper.

Keep the wire type loose and the domain type strict, and put the conversion in exactly one function so the rest of the codebase only ever sees the strict type. Parse the JSON into unknown, check that sume_status is one of the five strings, and only then cast to Job. A server that someday sends a sixth status should hit your default branch and page you, not be coerced into a pending state.

Envelope states and the next call, from Sume jobs and results (read 2026-10-06)
sume_statusterminalNext callFetch result?
queuedfalseGET status_url after next_poll_after_secondsNo
processingfalseGET status_url after next_poll_after_secondsNo
completedtrueGET result_url when result_ready is trueYes
failedtrueGET the job and read its errorNo
canceledtrueNone; cancel is idempotentNo

How do you test the union?

A union also makes the test cheap, and cheap tests are the ones that get written. Build one object per state, pass each to next, and assert the string. Because the type forbids a next_poll_after_seconds on a terminal state, a fixture that sets one fails to compile, which catches copy-paste mistakes in test data before they reach a run. Compile with tsc --noEmit in CI, and keep the never assertion in the default branch so the check survives refactors.

Resist adding a catch-all string to the status union to be lenient. It widens sume_status back to string, the compiler stops narrowing, and every branch needs a cast again. If you must accept an unknown future value, handle it before the cast, at the parse boundary, where one if can log it and reject the response.

Where does the error handling go?

The failed branch deserves a real handler, not a log line. The docs read a failure from job.error on GET /v1/jobs/{id}, so a function that takes a Dead job can fetch that object and decide between a retry with the same Idempotency-Key and a human page. That logic is its own topic; the point here is that the type gives it a single place to live. If you use @sume-com/sdk, its waitForJob helper already implements the polling loop, so reach for the union when you write your own client or when you need to show per-state progress in a UI.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume