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.
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 case | What to inspect |
|---|---|
| Transport | Separate an unavailable service from successful malformed data |
| Catalogue | Return normalized all-or-error records and useful issue paths |
| State | Apply 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
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.
// 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
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
EmptyExplain 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.
Completion is saved in this browser only. It does not measure mastery.