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.

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.
| sume_status | terminal | Next call | Fetch result? |
|---|---|---|---|
| queued | false | GET status_url after next_poll_after_seconds | No |
| processing | false | GET status_url after next_poll_after_seconds | No |
| completed | true | GET result_url when result_ready is true | Yes |
| failed | true | GET the job and read its error | No |
| canceled | true | None; cancel is idempotent | No |
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
- Unit test a transcription retry loop with a fake 429 in Python
Test your Sume STT retry code without calling the API: inject the POST and sleep, return a 429 with retry-after, and assert the same key is sent twice.
- Unity editor tool: generate an AI video clip with UnityWebRequest
A Unity coroutine posts a Wan 3.0 job to Sume, polls /v1/jobs/{id}/status with next_poll_after_seconds and downloads the MP4 with DownloadHandlerFile.
- Uptime monitor for the Sume API: probe GET /v1/me, spend reads
A 30 second probe of GET /v1/me costs two reads a minute against a 4,800 read Free budget. Python probe and a status table for 401, 429 and 5xx.
- What did one transcription job cost? GET /v1/usage by job_id
Read GET /v1/usage?job_id= and sum data.summary.debited_usd_micros to see what one Sume STT job really cost. Holds and refunds are not counted as spend.
Written by Sume