TypeScript union for a Sume /v1/videos poll with an exhaustive switch

Model the five poll statuses as a discriminated union so unsigned_urls only exists on completed and error only on failed. Compiles in strict mode, 30 lines.

5 min readSume
All posts

Type the Sume poll response as a union on status, and the compiler will stop you from reading unsigned_urls on a job that is not completed. The five statuses are pending, in_progress, completed, failed and cancelled. A switch with a never check in the default branch also fails the build if Sume adds a sixth.

The shape of the response

The route returns bare objects, not a { data } envelope, so the type is the response itself. Fields common to every state are id, polling_url, status, and the optional generation_id and model. The docs show unsigned_urls on completed jobs and error on failed ones, and the API source puts usage on any state once an amount exists.

Which poll fields belong to which status, from the Sume video docs (read 2026-10-08)
StatusExtra fields you can rely on
pendingnone
in_progressnone
completedunsigned_urls (one per output)
failederror (a single string)
cancellednone

The types and the switch

Put the types in one file and import them where you poll. The function describe returns text for each case; the never assignment is the exhaustiveness check. In a strict TypeScript project this compiles as shown.

type Base = {
  id: string;
  polling_url: string;
  generation_id?: string;
  model?: string;
  usage?: { cost: number | null };
};

export type VideoPoll =
  | (Base & { status: "pending" | "in_progress" | "cancelled" })
  | (Base & { status: "completed"; unsigned_urls: string[] })
  | (Base & { status: "failed"; error?: string });

export function describe(p: VideoPoll): string {
  switch (p.status) {
    case "pending": return "queued";
    case "in_progress": return "rendering";
    case "completed": return `ready: ${p.unsigned_urls[0]}`;
    case "failed": return `failed: ${p.error ?? "no reason given"}`;
    case "cancelled": return "cancelled";
    default: {
      const unreachable: never = p;
      return unreachable;
    }
  }
}

Using it in a loop

With the union in place, the polling loop is a plain while over await getPoll(url), and the only place that reads unsigned_urls is the completed branch. The compiler will reject p.unsigned_urls in any other branch, which removes a whole class of undefined is not iterable errors in production code that nobody tested with a failed job.

Where the type stops

A type is a promise, not a check. The JSON that comes off the wire can differ from it, so validate at the boundary if the data matters. Sume maps any status it does not recognise to failed on this route, which means the union is complete today, but keep the default branch so a future status is a compile error and not a silent hole.

If you also read jobs from /v1/jobs, remember that surface spells it canceled and uses queued and processing. Keep a second union for it; the three vocabularies post has the mapping, and generating a typed client from the OpenAPI JSON covers the other route.

  • unsigned_urls is a list because a model can return more than one output.
  • error is one string; there is no error object on the poll.
  • Do not reuse this type for webhook bodies, which use event and job_id.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume