Typed Schedule Tests

The Course
•
5 min read

A well-typed finish action can still belong to an old request. Build a controlled schedule with two requests: hold the first response, complete the second, then release the first. The state must remain the newer ready state.

A deferred promise needs an explicit resolver type when initialized later. One safe pattern initializes resolve as a no-op function with a known signature, then replaces it inside the Promise constructor. The test retains the function and uses it to choose completion order; no fixed timer delay is required.

A state reducer is deterministic. Test its stale-success and stale-failure behavior directly before wiring an async application. Then add a schedule test to show your caller generates and attaches ids correctly. Type tests can reject a string id where a number is required, but cannot prove the caller uses unique numeric ids.

Keep a runtime assertion that removes the temptation to cast state into ready: narrow by tag and inspect the actual data. If the branch is unexpected, fail the test instead of asserting the expected type.

Predict, run, and explain

Write the output and trace the important state changes before checking it. Then run the program. Use Check types first; execution is allowed only when the real compiler accepts the source.

Predict, then runTypeScript
let release: (value: string) => void = () => {};
const pending = new Promise<string>(resolve => {
  release = resolve;
});
const events: string[] = [];
const work = pending.then(value => {
  events.push(value);
});

events.push("new");
release("old");
await work;
console.log(events.join(","));

Enable JavaScript to edit and type-check this example.

Check your prediction
new,old

Assert both the pending and final ownership state

A schedule test should prove more than the final happy-path title. Observe that an older completion cannot end the newer loading state, and that an older error cannot overwrite the newer ready state. These are distinct points in the same lifecycle.

A deferred promise lets you choose the event order explicitly. Attach handlers before rejecting it so the fixture does not create an unrelated unhandled rejection. Await the operation that performs the state change, not an arbitrary microtask turn.

Assert both the pending and final ownership state: traceTypeScript
let release: (value: string) => void = () => {};
const old = new Promise<string>(resolve => {
  release = resolve;
});
let current: number = 2;
let status = "loading new";

function apply(request: number, value: string): void {
  if (request === current) {
    status = value;
  }
}

const work = old.then(value => apply(1, value));

release("old");
await work;
console.log(status);
apply(2, "new");
console.log(status);

Enable JavaScript to edit and type-check this example.

Check your prediction
loading new
new

The stale result is ignored while the current request is still loading. A guard based only on already-ready state would fail this test.

Assert both the pending and final ownership state: contrastTypeScript
let reject: (reason: unknown) => void = () => {};
const pending = new Promise<string>((resolve, fail) => {
  reject = fail;
});
const events: string[] = [];
const work = pending.catch(() => {
  events.push("handled");
});

reject(new Error("chosen failure"));
await work;
console.log(events.join(","));

Enable JavaScript to edit and type-check this example.

Check your prediction
handled

The rejection is released after a handler exists. The test controls the actual failure path rather than waiting for a timer and hoping a background request finishes.

Try the rule on new inputs

Cross old/new completion order with success/failure outcomes. Keep one test for a stale completion while newer work is pending and another after it is ready. State which guard each protects.

Your turn

Exercise

Typed Schedule Tests: practise

Create two deferred string promises and a refresh(load) function that allocates a new numeric id before awaiting its loader. Start the old refresh, then the new refresh. Complete the new request first and the old request last. Only the current id may update displayed. Assert the newer result is already visible before releasing the old one, then remains visible afterward. Print New.

Your workspaceTypeScript
// The identity comparison is a runtime guard, not a type assertion.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

A late promise can carry valid data that should no longer be applied.

Hint 2: a first step

Inside refresh, capture const request = ++currentRequest before await. Compare that captured id with currentRequest before assigning the title.

Show one solution
One possible solutionTypeScript
function deferred<T>() {
  let resolve: (value: T) => void = () => {};
  const promise = new Promise<T>(done => {
    resolve = done;
  });

  return { promise, resolve };
}

const old = deferred<string>();
const newer = deferred<string>();
let currentRequest = 0;
let displayed = "Loading";

async function refresh(load: () => Promise<string>): Promise<void> {
  const request = ++currentRequest;
  const title = await load();

  if (request === currentRequest) {
    displayed = title;
  }
}

function assertDisplayed(expected: string): void {
  if (displayed !== expected) {
    throw new Error("Wrong visible result");
  }
}

const first = refresh(() => old.promise);
const second = refresh(() => newer.promise);

newer.resolve("New");
await second;
assertDisplayed("New");
old.resolve("Old");
await first;
assertDisplayed("New");
console.log(displayed);

Enable JavaScript to edit and type-check this example.

New

Explain why the implementation meets the stated contract. Which part is enforced by the language or checker, and which part needs behavioral evidence?

Change it and test it

Temporarily remove the identity comparison and confirm the assertion fails. Add a catch branch inside refresh that displays an error only if its captured id is current; then reject the old request after the new one succeeds. The stale failure must also leave New visible. Explain how a reducer returning the exact same object for ignored completions makes the no-op contract easy to inspect.

Was this article helpful?

Your response is saved on this device.