Capstone: Typed Catalogue

The Course
•
6 min read

The complete catalogue boundary combines the week’s ideas. Domain types describe Book and Result<T>. decodeBook validates and projects one record, arrayOf adds row paths, and decodeCatalogue rejects duplicate normalized ids. A typed transport remains Promise<unknown> until that boundary succeeds.

The loader catches transport rejection narrowly and returns a useful failure result. The reducer accepts finish only for the current loading request. Types enforce shape and narrowing; runtime checks enforce data rules and request identity. Readonly is shallow, so it communicates access intent without pretending the runtime is frozen.

Read the source in layers rather than memorizing its length. First inspect the two result variants, then one decoder, then the array wrapper, then the loader and reducer. Finally trace the short caller at the end. Every construct has appeared earlier in the course.

Download the modular project for real files, compile-only contracts and runtime tests. Its transport is a local fixture, not an HTTP request. It demonstrates stale-result rejection, not cancellation or a UI. Top-level await keeps this combined editor program alive until its caller completes.

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
type Issue = {
  readonly path: string;

  readonly message: string;
};

type Result<T> =
  | {
      ok: true;

      value: T;
    }
  | {
      ok: false;

      issue: Issue;
    };

type Decoder<T> = (value: unknown) => Result<T>;

type Book = {
  readonly id: string;

  readonly title: string;

  readonly pages: number;
};

const decodeBook: Decoder<Book> = value => {
  if (typeof value !== "object" || value === null) {
    return { ok: false, issue: { path: "book", message: "Expected a record" } };
  }

  if (
    !("id" in value) ||
    typeof value.id !== "string" ||
    value.id.trim() === ""
  ) {
    return {
      ok: false,
      issue: { path: "id", message: "Expected nonblank text" }
    };
  }

  if (
    !("title" in value) ||
    typeof value.title !== "string" ||
    value.title.trim() === ""
  ) {
    return {
      ok: false,
      issue: { path: "title", message: "Expected nonblank text" }
    };
  }

  if (
    !("pages" in value) ||
    typeof value.pages !== "number" ||
    !Number.isFinite(value.pages) ||
    !Number.isInteger(value.pages) ||
    value.pages < 0
  ) {
    return {
      ok: false,
      issue: { path: "pages", message: "Expected a nonnegative integer" }
    };
  }

  return {
    ok: true,
    value: {
      id: value.id.trim(),
      title: value.title.trim(),
      pages: value.pages
    }
  };
};

function arrayOf<T>(decode: Decoder<T>): Decoder<readonly T[]> {
  return value => {
    if (!Array.isArray(value)) {
      return {
        ok: false,
        issue: { path: "list", message: "Expected an array" }
      };
    }

    const output: T[] = [];

    for (const [index, element] of value.entries()) {
      const item: unknown = element;
      const result = decode(item);

      if (!result.ok) {
        return {
          ok: false,
          issue: {
            path: `${index}.${result.issue.path}`,
            message: result.issue.message
          }
        };
      }

      output.push(result.value);
    }

    return { ok: true, value: output };
  };
}

const decodeCatalogue: Decoder<readonly Book[]> = value => {
  const result = arrayOf(decodeBook)(value);

  if (!result.ok) {
    return result;
  }

  const ids = new Set<string>();

  for (const [index, book] of result.value.entries()) {
    if (ids.has(book.id)) {
      return {
        ok: false,
        issue: { path: `${index}.id`, message: "Duplicate id" }
      };
    }

    ids.add(book.id);
  }

  return result;
};

type State =
  | { tag: "idle" }
  | {
      tag: "loading";

      request: number;
    }
  | {
      tag: "ready";

      request: number;

      books: readonly Book[];
    }
  | {
      tag: "error";

      request: number;

      message: string;
    };

type Action =
  | {
      type: "start";

      request: number;
    }
  | {
      type: "finish";

      request: number;

      result: Result<readonly Book[]>;
    };

function reduce(state: State, action: Action): State {
  switch (action.type) {
    case "start":
      return { tag: "loading", request: action.request };
    case "finish": {
      if (state.tag !== "loading" || state.request !== action.request) {
        return state;
      }

      if (action.result.ok) {
        return {
          tag: "ready",
          request: action.request,
          books: action.result.value
        };
      }

      return {
        tag: "error",
        request: action.request,
        message: `${action.result.issue.path}: ${action.result.issue.message}`
      };
    }
    default:
      return unreachable(action);
  }
}

function unreachable(value: never): never {
  throw new Error("Unhandled action");
}

type Transport = () => Promise<unknown>;

function createLoader(
  transport: Transport
): () => Promise<Result<readonly Book[]>> {
  return async () => {
    let value: unknown;

    try {
      value = await transport();
    } catch (error) {
      return {
        ok: false,
        issue: {
          path: "transport",
          message: error instanceof Error ? error.message : "Request failed"
        }
      };
    }

    return decodeCatalogue(value);
  };
}

const load = createLoader(async () => [
  { id: "b1", title: " Types ", pages: 120 }
]);
let state = reduce({ tag: "idle" }, { type: "start", request: 1 });

state = reduce(state, { type: "finish", request: 1, result: await load() });

if (state.tag === "ready") {
  console.log(state.books.map(book => book.title).join(","));
}

Enable JavaScript to edit and type-check this example.

Check your prediction
Types

Trace a request through three independent contracts

The transport supplies unknown because arrival is not validation. createLoader awaits it, converts transport rejection, then calls decodeCatalogue outside that catch. The decoder constructs trusted rows and checks normalized identities across the list. A successful decoded list still needs request ownership before it becomes current state: good data can belong to an old request.

Boundary or caseWhat to inspect
TransportSeparate an unavailable service from successful malformed data
CatalogueReturn normalized all-or-error records and useful issue paths
StateApply current loading completion and preserve exact state for stale work

Trace an invalid second row to its prefixed path, then trace an old valid result while newer work is held. These cases protect different boundaries. Do not describe HTTP success or a typed finish action as proof that a state update should occur.

Your turn

Exercise

Capstone: Typed Catalogue: practise

Rebuild a trusted Book title formatter from memory: accept readonly Book[] and return their titles joined by comma. Test two books and an empty list, printing Types,API and Empty via a caller fallback. This function must not parse or validate external JSON itself.

Your workspaceTypeScript
// Keep trusted-domain formatting separate from the unknown-data boundary.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

Once validation establishes Book, a formatter should not repeat the importer.

Hint 2: a first step

Use a readonly array parameter and map only the known title field.

Show one solution
One possible solutionTypeScript
type Book = {
  readonly id: string;

  readonly title: string;

  readonly pages: number;
};

function titles(books: readonly Book[]): string {
  return books.map(book => book.title).join(",");
}

console.log(
  titles([
    { id: "a", title: "Types", pages: 10 },
    { id: "b", title: "API", pages: 20 }
  ])
);
console.log(titles([]) || "Empty");

Enable JavaScript to edit and type-check this example.

Types,API
Empty

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

Run the full example with a bad page count, blank title, duplicate normalized id and rejected transport. Trace which layer rejects each. Explain why the formatter should not be handed unvalidated unknown data under a Book[] assertion.

Was this article helpful?

Your response is saved on this device.