web · Level 3
TypeScript: types for safer web applications
Check incoming data, model display states and keep static checks separate from runtime evidence.

Start with the essentials
The short answer
TypeScript checks relationships between declared types before a program runs. It does not validate incoming JSON or remove the need for tests. This lab turns a fictional reading list into checked records and explicit display states, then tests both compiler rejections and runtime behaviour. The same boundary can support a web interface without treating a successful build as browser acceptance.
What you will learn
- Configure a reproducible strict TypeScript project.
- Validate unknown JSON before creating typed records.
- Use unions and generics to model success and failure.
- Handle optional properties and indexed access explicitly.
- Test rejected types separately from executable behaviour.
- Plan gradual adoption while retaining browser and accessibility checks.
Who it is for
JavaScript learners who can read functions and arrays and want a practical first TypeScript project.
Before you start
- Read JavaScript functions, arrays, objects and basic assertions.
- Use a terminal and create a small folder tree.
- Have Node.js and npm available; the compiler installation needs a registry connection.
Read a sample · Chapter 01 of 06
Set up one small, reproducible project
Make the compiler a visible part of the work, with a narrow job and explicit inputs.
Our fictional reading list contains at most twelve courses. Each record has an ID, a title and a duration. An optional note may be absent or may contain empty text. The application reads JSON text, validates every row and returns either the complete checked list or a useful failure. A second layer turns that result into empty, error or ready display state. This project is a data boundary and display-text model, not a finished browser interface.
Create package.json, tsconfig.json, src/catalogue.ts, test/types.ts, test/runtime.mjs and demo.mjs in a new folder. Every file appears in full below. If a file spans several blocks, join its blocks in order with one blank line between them. The file paths are significant: the tests import the JavaScript that the compiler places under build/src. Keep this lab separate from a working application so that its small configuration does not overwrite your existing build.
Save package.json first. TypeScript 5.9.3 is pinned for this reproducible exercise; it is not presented as the newest compiler or a deployment recommendation. The release ran on Node.js 24.15.0. The project has one development dependency and no runtime library dependency. A different compiler can change diagnostics or configuration requirements, so treat an upgrade as a change to test rather than quietly changing the version while comparing results.
{
"name": "mickai-typescript-boundary-lab",
"private": true,
"type": "module",
"scripts": {
"check": "tsc --noEmit",
"build": "tsc",
"test": "npm run check && npm run build && node --test test/runtime.mjs",
"demo": "npm run build && node demo.mjs"
},
"devDependencies": { "typescript": "5.9.3" }
}Save tsconfig.json. Strict mode groups several compiler checks. We add indexed-access and exact-optional-property checks explicitly because this lab depends on their behaviour. RootDir and outDir keep emitted files separate from source. The include list covers the application and the type test, while the runtime test remains ordinary JavaScript. Types is empty so unrelated ambient packages do not quietly contribute globals. ES2022 supplies standard language declarations without pretending that this project has a browser DOM.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": [],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmitOnError": true,
"rootDir": ".",
"outDir": "build"
},
"include": ["src/**/*.ts", "test/types.ts"]
}After creating every file, install with npm install --ignore-scripts --no-audit --no-fund, then run npm test and npm run demo. The install creates package-lock.json; retain it for the project, and use npm ci --ignore-scripts on later clean installs. The checked repository lab includes that lockfile. Installation needs network access initially; the actual example tests use no network service, account, API key or paid tool. Add node_modules and build to your own ignore file.
The test command chains checking, compilation and Node tests with &&. A failed earlier step stops later steps. NoEmitOnError prevents a failing compilation from writing new output, but it does not remove old files already sitting in build. Do not run a stale emitted file and mistake its success for evidence that the current source compiled. The release checks this option in a fresh temporary output directory.
Try it yourself · Activity 01
15 minTrace the build boundary
Explain what each command proves before running it.
- Identify the two TypeScript inputs selected by the configuration.
- Predict whether an old build file disappears after a compiler error.
- Write the install, check, test and demo commands in execution order.
Can someone reproduce the compiler version and tell whether they executed current or stale output?
Worked answer
The include patterns select src TypeScript files and test/types.ts. An error prevents new emission with this configuration but leaves older output alone. Install the pinned dependency, run npm run check, then npm test and npm run demo. The latter commands rebuild before executing. None of these commands demonstrates browser layout, network failure handling or keyboard access.
Read a sample · Chapter 02 of 06
Give valid data a useful shape
Use a small domain type, then construct it through explicit checks.
Save src/catalogue.ts from all its blocks in chapters two, three and four, preserving that order. Course describes the values the rest of the application wants to use. Readonly discourages mutation through a typed reference; it does not freeze the object in JavaScript. Result<T> is a reusable success-or-failure container. T means that the success value keeps its own type. The failure has an error string and deliberately has no success value.
View uses a tag to distinguish three cases. Empty needs no list; error needs a message; ready needs courses. This prevents some meaningless combinations that a single object full of optional properties would allow. It does not encode every business invariant: a ready view can still contain an empty array. The display function handles that defensively rather than claiming that ordinary array typing proves non-emptiness.
export type Course = Readonly<{
id: string;
title: string;
minutes: number;
note?: string;
}>;
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
export type View =
| { tag: "empty" }
| { tag: "error"; message: string }
| { tag: "ready"; courses: readonly Course[] };
function record(value: unknown):
value is Record<string, unknown> {
return typeof value === "object" && value !== null
&& !Array.isArray(value);
}function decodeCourse(value: unknown): Result<Course> {
if (!record(value)) {
return { ok: false, error: "Expected a record." };
}
const { id, title, minutes, note } = value;
if (typeof id !== "string"
|| !/^[a-z][a-z0-9-]{0,23}$/.test(id)) {
return { ok: false, error: "Invalid course ID." };
}
if (typeof title !== "string"
|| title.trim().length < 1
|| title.trim().length > 60) {
return { ok: false, error: "Invalid title." };
}
if (typeof minutes !== "number"
|| !Number.isInteger(minutes)
|| minutes < 1 || minutes > 240) {
return { ok: false, error: "Invalid minutes." };
}
if ("note" in value
&& (typeof note !== "string" || note.length > 120)) {
return { ok: false, error: "Invalid note." };
}
const course: Course = { id, title: title.trim(), minutes };
return { ok: true, value: typeof note === "string"
? { ...course, note } : course };
}Unknown marks a value we have not established enough about to use directly. The record helper rules out null and arrays before allowing property reads as unknown values. Its return annotation is a type predicate. The compiler trusts that predicate, so the implementation deserves the same scrutiny as any parser. An incorrect predicate could declare a number safe while leaving the number unchanged. Keep these helpers small and test representative invalid inputs.
Our ID policy is lower-case ASCII letters followed by letters, digits or hyphens, with length one to twenty-four. Titles are trimmed before checking their length from one to sixty. Durations must be integers from one to 240; the string "25" is rejected rather than coerced. These are decisions for a tiny fictional list, not rules about real course durations. A business change must alter the policy, tests and displayed explanation together.
The optional note receives a separate presence check. Null, a number or text longer than 120 is rejected. Empty text is valid and retained, whereas an absent property stays absent. The conditional object construction avoids manufacturing note: undefined. We make a new record containing only recognised fields. Extra JSON fields are intentionally ignored. This projection narrows what downstream code sees, but it is neither authorisation nor a statement that unknown fields are safe in another application.
String lengths here count JavaScript UTF-16 code units, not bytes or user-perceived characters. A supplementary Unicode character can occupy two units. That matters when translating the limit into a user message. The parser accepts ordinary text including angle brackets in titles. Such text is data; a future UI must insert it as text, not treat it as executable markup. Static string typing provides no HTML sanitisation.
Try it yourself · Activity 02
20 minSeparate absence, text and numbers
Predict which records survive the policy, then check the runtime tests.
- Compare an absent note, note: "", and note: null.
- Compare minutes: 25, minutes: "25", and minutes: 1.5.
- Add admin: true to a valid record and describe the returned value.
Which of your real fields needs a deliberate distinction between missing, empty and invalid?
Worked answer
Absent note and empty note are accepted and remain distinct; null is rejected. Numeric 25 is accepted, while its string form and 1.5 are rejected. The extra admin property is omitted from the newly constructed record. None of these decisions establishes user identity or permission to access a course.
Read a sample · Chapter 03 of 06
Validate the entire boundary
Parse syntax first, then establish the shape and business rules.
Check the values that arrive
Before execution, const minutes: number = "25"; is rejected by the pinned
TypeScript 5.9.3 compiler with diagnostic 2322. With noEmitOnError enabled,
the isolated failing probe emits no JavaScript. This checks a source-level contract;
type annotations are erased from emitted JavaScript. They do not inspect incoming JSON.
At runtime, parseCourses first limits the supplied text to 4,096 UTF-16
code units, then attempts JSON.parse and assigns the result to
unknown. Valid JSON syntax does not establish a valid course. Explicit
checks require an array of at most twelve records, valid IDs and titles, integer minutes
from 1 to 240 and a valid optional note. Repeated course IDs fail the whole list.
The character limit is applied after receiving a string; it is not a network byte limit.
The valid fixture contains Alpha with 25 minutes and Beta with 40 minutes. Alpha's title
arrives with leading and trailing spaces and an extra extra field;
its note property is absent. Beta has note: "".
The invalid minutes fixture contains a string "25", not the number 25.
The duplicate fixture repeats the ID alpha in two records. Broken JSON is
the single character {. Empty input here means the valid JSON array
[], not an empty string.
| Input fixture | View tag | statusText output |
|---|---|---|
| Alpha + Beta | ready | 2 courses. First: Alpha. |
| Empty array | empty | No courses yet. |
| Broken JSON | error | Cannot read list: Invalid JSON. |
| minutes is "25" | error | Cannot read list: Invalid minutes. |
| Repeated alpha ID | error | Cannot read list: Duplicate course ID. |
Validation builds fresh records. These four observations use the successful two-course
fixture; toView then chooses ready.
| Incoming field | Checked record |
|---|---|
| Alpha title | Spaces trimmed to Alpha |
| Alpha extra field | Omitted from the new record |
| Alpha absent note | Property stays absent |
| Beta empty note | Empty string is retained |
Result carries either success with checked courses or failure with a message.
toView maps failure to error, successful zero-length arrays to
empty, and nonempty arrays to ready. statusText
switches on the tag. These are plain strings; the diagram does not demonstrate browser
rendering or HTML sanitisation.
readonly prevents certain writes in checked TypeScript; it does not freeze
the runtime objects. A type assertion is not validation. The published parser validates
this small catalogue format, not arbitrary JavaScript objects or untrusted network
transport. Extra fields are projected away. Repeated JSON property names have already
been resolved by JSON.parse; only repeated course IDs are rejected here.
Continue src/catalogue.ts with parseCourses. The public input is text: the TypeScript caller must supply a string. JSON.parse can produce many shapes, so its result is immediately assigned to unknown. The runtime parser then checks the collection and each row. A type assertion such as as Course[] would skip that work; it changes the compiler view without converting or validating the value.
export function parseCourses(text: string):
Result<readonly Course[]> {
if (text.length > 4096) {
return { ok: false, error: "Input is too long." };
}
let value: unknown;
try {
value = JSON.parse(text);
} catch {
return { ok: false, error: "Invalid JSON." };
}
if (!Array.isArray(value) || value.length > 12) {
return { ok: false, error: "Expected at most 12 courses." };
}
const courses: Course[] = [];
const ids = new Set<string>();
for (const raw of value) {
const decoded = decodeCourse(raw);
if (!decoded.ok) return decoded;
if (ids.has(decoded.value.id)) {
return { ok: false, error: "Duplicate course ID." };
}
ids.add(decoded.value.id);
courses.push(decoded.value);
}
return { ok: true, value: courses };
}The text cap is checked before JSON parsing. It limits this function to 4,096 code units of already-received text. It does not cap an HTTP request before the body is buffered, and it is not a network byte limit. A server using this boundary still needs its own request-size control; a browser fetching data should consider response and resource handling separately. Keep the scope of a check attached to the operation it actually bounds.
An empty array is valid data. Thirteen rows are rejected before individual validation. For each accepted row the Set remembers its ID. A repeated ID fails the whole list, preserving a simple all-or-nothing contract: downstream code never needs to decide whether a truncated result is complete. A failure in a later row also discards earlier accepted rows. We return the first error, not a comprehensive report of every mistake.
The local mutable courses array exists only while constructing the result. Returning it as readonly restricts typed callers, but does not provide a security boundary or deep runtime immutability. A JavaScript caller can bypass the compiler, and a type assertion can override the intended type. In this design JSON parsing and record projection create fresh data with no reference back to the original text. That is useful ownership discipline, without claiming Object.freeze or a hostile-object sandbox.
Duplicate object keys are a different issue from duplicate course IDs. The ordinary JSON parser keeps the last value of a repeated property name. Our validation sees that final object, so a minutes key followed by another minutes key is not detected as a duplicate property. A runtime test records this policy explicitly. Applications that must reject duplicate keys need a parser or input format that preserves the required evidence before it disappears.
Try it yourself · Activity 03
20 minFind the first failing boundary
Work out the outcome of a small input matrix without changing the parser.
- Compare malformed JSON, a top-level object, an empty list and a thirteen-row list.
- Put an invalid duration in the second row after a valid first row.
- Contrast two rows with the same ID and two minutes keys in a single JSON object.
Would your users need the first error, every error, or a carefully labelled partial result?
Worked answer
Malformed syntax returns Invalid JSON. A top-level object or thirteen-row list returns the collection error. An empty list succeeds. An invalid second row fails the complete list without returning a partial value. Repeated course IDs fail after row decoding; duplicate JSON property names have already been reduced to the parser's last value and are not separately rejected.
Read a sample · Chapter 04 of 06
Turn the result into an explicit display state
Keep presentation decisions readable and make new cases visible to the compiler.
Complete src/catalogue.ts with these final functions. ToView handles the success flag before accessing value. StatusText handles the tag before reading case-specific properties. In each branch, the discriminant narrows what is available. The default path passes its input to a function requiring never. Add a new View variant without updating the switch and compilation fails at that call; this is a useful change detector for a closed set of states.
export function toView(result: Result<readonly Course[]>): View {
if (!result.ok) {
return { tag: "error", message: result.error };
}
return result.value.length === 0
? { tag: "empty" }
: { tag: "ready", courses: result.value };
}
function unreachable(value: never): never {
throw new Error("Unhandled view.");
}export function statusText(view: View): string {
switch (view.tag) {
case "empty": return "No courses yet.";
case "error": return `Cannot read list: ${view.message}`;
case "ready": {
const first = view.courses[0];
const count = view.courses.length;
const noun = count === 1 ? "course" : "courses";
return first
? `${count} ${noun}. First: ${first.title}.`
: "No courses yet.";
}
default: return unreachable(view);
}
}The first indexed course may be missing because arrays can be empty. NoUncheckedIndexedAccess makes that possibility visible in the type. We check first before reading its title and use the empty message for a manually created ready state with no rows. A non-null assertion would silence the compiler here without adding a runtime check. Choose the explicit branch because our ordinary array type permits the empty case.
The returned string is a display model. It is not a DOM node, an HTML fragment or proof that a screen reader announced anything. A browser adapter should set an element's textContent, give controls meaningful names, retain usable focus and expose changing status appropriately. Test those behaviours in the real UI. Rendering arbitrary titles with innerHTML would introduce a separate risk even though the title has the static type string.
This deliberately synchronous project does not fetch data. If you later add loading state, cancellation or overlapping requests, extend the model and test stale-response ordering. A four-case type helps you enumerate states but cannot determine which request finished last or which result should win. Keep a request identity or other explicit lifecycle policy at the asynchronous boundary instead of hiding it behind a type assertion.
The unreachable function also throws if untyped JavaScript sends an unknown tag at runtime. It does not validate every forged ready or error object. StatusText is an internal consumer of our typed view model, not another public JSON decoder. Every exported function has a stated caller contract; only parseCourses performs the external-text validation shown here. The distinction prevents readers from assuming that every function accepting a type validates that type at runtime.
Try it yourself · Activity 04
15 minMake a missing case fail visibly
Add a loading case in a scratch copy and observe the exhaustiveness error.
- Extend View with a loading tag but leave statusText unchanged.
- Run the compiler and locate the call that cannot accept the new variant.
- Add a loading branch, then decide what separate asynchronous tests would be needed.
Can adding a new state make every affected consumer visible during review?
Worked answer
The new loading variant reaches the default branch, where it cannot be passed as never. A loading case with a text result resolves that static omission. It does not establish cancellation, request ordering, focus behaviour or status announcements. Those require application logic and runtime/browser checks suited to the adapter.
Read a sample · Chapter 05 of 06
Test the compiler and the running program
Use different checks for invalid types and invalid data.
Save test/types.ts. The rejectedExamples function is deliberately never called. Its seven expected-error comments describe compile-time constraints: unknown access, unchecked indexing, explicit undefined for an optional property, an incomplete state, readonly mutation, unrefined result access and a wrong input argument. The compiler checks the body without executing it. Do not import this file as a demonstration of safe runtime behaviour.
import { parseCourses, statusText } from "../src/catalogue.js";
import type { Course, Result, View } from "../src/catalogue.js";
// Compile this function. Never call it: its errors are intentional.
function rejectedExamples(input: unknown, items: readonly Course[]) {
// @ts-expect-error unknown input needs a runtime check
input.title;
// @ts-expect-error indexed item may be absent
const missing: Course = items[0];
// @ts-expect-error note can be absent, not explicitly undefined
const badNote: Course = { ...fixture, note: undefined };
// @ts-expect-error ready state needs courses
const incomplete: View = { tag: "ready" };
// @ts-expect-error readonly array cannot be appended to
items.push({ id: "b", title: "B", minutes: 2 });
const result = parseCourses("[]");
// @ts-expect-error failure branch has no value
result.value;
// @ts-expect-error number is not the declared text contract
parseCourses(12);
}const fixture = {
id: "alpha", title: "Alpha", minutes: 25
} satisfies Course;
const accepted: Result<readonly Course[]> = {
ok: true, value: [fixture]
};
statusText({ tag: "ready", courses: accepted.value });An expected-error comment must correspond to a diagnostic on the next line. If that line becomes valid, the compiler reports the unused directive. It is not a general waiver for surrounding code. The small fixture uses satisfies to check compatibility while retaining its inferred type; the operator neither coerces the data nor invokes our JSON decoder. Keep a positive example beside the rejected cases so the contract is not defined entirely by failures.
Save test/runtime.mjs. Node's test runner executes the emitted JavaScript and reports assertion failures. These tests exercise the actual JSON boundary, including syntax, lengths, value ranges, optional fields, duplicates, projection and display states. They use fictional records only. The test helpers are ordinary JavaScript, so successful runtime tests do not replace the compiler tests of typed call sites.
import test from "node:test";
import assert from "node:assert/strict";
import { parseCourses, toView, statusText }
from "../build/src/catalogue.js";
const item = { id: "alpha", title: " Alpha ", minutes: 25 };
const parse = value => parseCourses(JSON.stringify(value));
const fails = (value, message) => {
assert.deepEqual(parse(value), { ok: false, error: message });
};
test("valid data is copied, trimmed and projected", () => {
const result = parse([{ ...item, admin: true }]);
assert.deepEqual(result, { ok: true, value: [
{ id: "alpha", title: "Alpha", minutes: 25 }
] });
});
test("empty list is a successful empty view", () => {
assert.deepEqual(parse([]), { ok: true, value: [] });
assert.deepEqual(toView(parse([])), { tag: "empty" });
assert.equal(statusText(toView(parse([]))), "No courses yet.");
});test("invalid JSON and input length are separate", () => {
assert.equal(parseCourses("[").error, "Invalid JSON.");
assert.equal(parseCourses(" ".repeat(4097)).error,
"Input is too long.");
assert.equal(parseCourses("[]" + " ".repeat(4094)).ok, true);
});
test("top-level shape and collection bounds", () => {
for (const value of [null, {}, 1, "text", true]) {
fails(value, "Expected at most 12 courses.");
}
const rows = Array.from({ length: 12 }, (_, i) =>
({ ...item, id: `a${i}` }));
assert.equal(parse(rows).ok, true);
fails([...rows, { ...item, id: "extra" }],
"Expected at most 12 courses.");
});
test("each row must be a record", () => {
for (const value of [null, [], 1, "text", false]) {
fails([value], "Expected a record.");
}
});test("ID syntax and boundaries", () => {
for (const id of ["", "1start", "UPPER", "a b", "a".repeat(25), null]) {
fails([{ ...item, id }], "Invalid course ID.");
}
for (const id of ["a", "a".repeat(24), "a-1"]) {
assert.equal(parse([{ ...item, id }]).ok, true);
}
});
test("title trims before checking length", () => {
for (const title of ["", " ", "x".repeat(61), 12, null]) {
fails([{ ...item, title }], "Invalid title.");
}
assert.equal(parse([{ ...item, title: " x " }]).ok, true);
assert.equal(parse([{ ...item, title: "x".repeat(60) }]).ok, true);
});test("minutes are bounded integers without coercion", () => {
for (const minutes of [0, -1, 241, 1.5, "25", null, true]) {
fails([{ ...item, minutes }], "Invalid minutes.");
}
for (const minutes of [1, 240]) {
assert.equal(parse([{ ...item, minutes }]).ok, true);
}
assert.equal(parseCourses('[{"id":"a","title":"A","minutes":1e999}]').ok,
false);
});
test("note is optional and empty text is preserved", () => {
assert.equal(Object.hasOwn(parse([item]).value[0], "note"), false);
for (const note of ["", "x".repeat(120)]) {
assert.equal(parse([{ ...item, note }]).value[0].note, note);
}
for (const note of [null, 0, false, "x".repeat(121)]) {
fails([{ ...item, note }], "Invalid note.");
}
});
test("duplicates and invalid later rows fail the whole list", () => {
fails([item, item], "Duplicate course ID.");
fails([item, { ...item, id: "beta", minutes: 0 }], "Invalid minutes.");
});test("JSON duplicate keys follow the parser's last value", () => {
const text = '[{"id":"a","title":"A","minutes":0,"minutes":1}]';
assert.equal(parseCourses(text).value[0].minutes, 1);
});
test("all view variants produce literal text", () => {
assert.equal(statusText(toView(parse([item]))), "1 course. First: Alpha.");
assert.equal(statusText(toView(parse(null))),
"Cannot read list: Expected at most 12 courses.");
assert.equal(statusText({ tag: "ready", courses: [] }), "No courses yet.");
const text = statusText(toView(parse([{ ...item, title: "<b>Hi</b>" }])));
assert.equal(text, "1 course. First: <b>Hi</b>.");
});Run npm test from the lab root. The reviewed release passed all twelve named runtime tests with TypeScript 5.9.3 and Node.js 24.15.0. The numerical limits are exercised on both sides, including an overflowing JSON numeric literal that produces a non-integer value. This is a bounded test set, not exhaustive exploration of all text, Unicode, engine behaviours or UI states.
The release also checked whether the tests are capable of failing. Removing the seven expected-error comments produced seven compiler diagnostics and no emitted JavaScript in a fresh output directory. Adding loading without a switch case failed exhaustiveness. Turning off exact optional-property checking made its expected-error directive fail as unused. Three separate valid-code defects were compiled and then caught by runtime assertions: removing the duration ceiling, ignoring duplicate IDs and losing the empty view.
A compiler failure caused by a syntax typo would not demonstrate a runtime test detecting a bad business rule. That is why the three faulty implementations must compile before their runtime checks are accepted as evidence. Keep these experiments in disposable copies; do not leave a weakened parser in the learner project. The original source remains unchanged after each fault check.
Try it yourself · Activity 05
20 minChoose the right failing test
Use a scratch copy to distinguish static and behavioural regressions.
- Remove the duration upper bound but keep valid TypeScript syntax.
- Run the compiler, then the runtime tests, and identify which layer detects the defect.
- Restore it and disable exactOptionalPropertyTypes; explain the different failure.
Would each important rule in your project fail visibly if someone removed it?
Worked answer
The duration-ceiling change still compiles, but the 241-minute runtime case fails. Disabling exact optional-property checking instead makes the note: undefined example legal, so its expected-error directive produces an unused-directive diagnostic. Restore both changes. A compiler pass and a runtime pass are separate pieces of evidence.
Read a sample · Chapter 06 of 06
Run the example and migrate gradually
Carry the useful boundary into a real project without expanding its claims.
Save demo.mjs, then run npm run demo. It imports only the application output, constructs two fictional records and prints the three possible outcomes. The optional empty note on Beta is retained even though the display text does not use it. No file is fetched, no browser is launched and no data is sent to a service by this example.
import { parseCourses, toView, statusText }
from "./build/src/catalogue.js";
const text = JSON.stringify([
{ id: "alpha", title: "Alpha", minutes: 25 },
{ id: "beta", title: "Beta", minutes: 40, note: "" }
]);
console.log(statusText(toView(parseCourses(text))));
console.log(statusText(toView(parseCourses("[]"))));
console.log(statusText(toView(parseCourses("["))));2 courses. First: Alpha.
No courses yet.
Cannot read list: Invalid JSON.For an existing JavaScript application, begin with one boundary that has a clear input, output and test. Record the behaviour before conversion. Use allowJs when JavaScript must coexist with TypeScript, and consider checkJs or a targeted ts-check comment to expose problems before renaming everything. Those migration options are not enabled in this deliberately small lab; do not copy its restrictive include list over a larger project and assume all existing files are checked.
Move a small pure function first, then its shared data types and callers. Replace implicit assumptions with explicit checks where input crosses a trust boundary. Resist solving every error with any or a broad assertion. Some diagnostics reveal a real bug, some reveal an underspecified contract and some need a declaration for an external library. Make the smallest justified change and retain the behaviour tests while deciding which category applies.
The lab uses NodeNext and a package marked as an ES module. Relative TypeScript imports use the .js extension expected by the emitted runtime files. A browser bundler, a server framework and an unbundled browser module can each need a different configuration. Do not assume that the configuration here is a universal web build recipe. Keep runtime targets, module resolution and deployment assets consistent with the host you actually use.
A migration checklist should include data decoding, optional values, module paths, build failure handling and executable tests. Add real browser checks for keyboard access, focus, error recovery and status updates when you connect a view. Check that the no-JavaScript fallback still provides the promised content. Types can support the adapter design, but neither types nor this Node lab verify the experience of a person using a browser.
Your final handover should state the tool versions, complete file set, compiler command, test result and limitations. Include the lockfile and a small input/output example. Explain where external data enters and where it becomes trusted application data. Mark any assertion or third-party declaration that bypasses direct runtime evidence. That makes the next change reviewable instead of leaving a green build as the only description of quality.
Try it yourself · Activity 06
15 minWrite a one-boundary migration plan
Apply the lesson to an existing JavaScript feature without claiming the whole application is converted.
- Choose one input boundary and write its current success and failure examples.
- List a small type, runtime decoder and caller that can be changed together.
- Name the compiler, runtime and browser checks needed before the change ships.
What remains unverified after your migration slice is complete, and who can see that limitation?
Worked answer
A suitable first slice is a saved-list JSON reader plus the pure function that turns its result into display state. Preserve known examples, add explicit decoding and type the caller. Run the compiler and data tests, then check the real adapter with keyboard input, error recovery and status announcements. Record asynchronous ordering or storage failure cases separately if the feature has them.
Keep learning
The complete workbook
Work through six complete files for a small TypeScript project. Pin its compiler, validate unknown JSON, preserve optional values, handle unions exhaustively and compare compile-time tests with runtime assertions. Six worked activities finish with a practical gradual-migration plan.
- 01Set up one small, reproducible projectRead here · 1 exercise
Make the compiler a visible part of the work, with a narrow job and explicit inputs.
- 02Give valid data a useful shapeRead here · 1 exercise
Use a small domain type, then construct it through explicit checks.
- 03Validate the entire boundaryRead here · 1 exercise
Parse syntax first, then establish the shape and business rules.
- 04Turn the result into an explicit display stateRead here · 1 exercise
Keep presentation decisions readable and make new cases visible to the compiler.
- 05Test the compiler and the running programRead here · 1 exercise
Use different checks for invalid types and invalid data.
- 06Run the example and migrate graduallyRead here · 1 exercise
Carry the useful boundary into a real project without expanding its claims.
Also inside: a 8-point checklist, a glossary of 10 terms and 10 questions and answers to test yourself. 6 hands-on exercises, each with a worked answer at the back where the workbook gives one.
No login, no card, no account. Before the download we ask you to follow Mickai (two quick links). Free to download and use for personal learning, study groups and inside your own team. Please do not resell the workbooks or republish them as your own. Link people to trust-agent.ai instead.
Test yourself
Questions and answers
Does a type assertion validate JSON?
No. An assertion changes the compiler view without checking or converting the runtime value. Parse into unknown and validate the fields before constructing a Course.
Why use unknown instead of any?
Unknown requires evidence before property use. Any permits operations that escape useful checking. Neither type performs runtime validation by itself.
Does readonly freeze an object?
No. It limits mutation through checked references. The emitted JavaScript is not automatically frozen, and untyped callers can bypass the restriction.
Is an empty note the same as no note?
Not in this contract. Empty text is an accepted value; an absent property stays absent. Null is rejected.
Why is the first array entry possibly missing?
A normal array may be empty. With the indexed-access option enabled, the read includes undefined and needs a check before property access.
Does the parser reject duplicate JSON keys?
No. Ordinary JSON parsing keeps the last property value before this validation sees the object. Duplicate course IDs across rows are a separate rule that is rejected.
What happens when a new view tag is added?
An unhandled variant reaches the never check and causes a compiler error. Adding the branch resolves the static omission but does not test asynchronous behaviour.
Should I execute the rejected type examples?
No. They are compiler tests inside a function that is never called. The runtime tests are a different file and execute the built application code.
Does noEmitOnError clear old output?
No. It prevents new output from the failed compilation. Old files can remain, so commands must stop after failure and clean-output checks should use a separate temporary location.
Is this a complete accessible web application?
No. It is a tested data boundary and display-text model. The browser adapter, focus behaviour, keyboard access, announcements and network lifecycle need their own implementation and verification.
When you have finished
Get your certificate of completion
Type your name and download a certificate for this workbook as a PDF, ready to print or to add to LinkedIn. It is made on your own device, so your name is never sent to us. It is a self-declared certificate, not an accredited qualification.
Learn the language
Key terms
- Static type
- A description used by the compiler to check how values may be used before execution.
- Unknown
- A type for a value whose usable shape has not yet been established.
- Narrowing
- Refining the possible type of a value after a check or control-flow decision.
- Discriminated union
- A set of object variants distinguished by a shared property with literal values.
- Generic
- A type or function parameter that preserves a relationship across different supplied types.
- Type predicate
- A function return annotation that tells the compiler which type a successful check establishes.
6 of the workbook's 10 terms. The complete glossary is in the workbook.
Follow the evidence
Sources and checks
Facts last checked: .
Examples in this workbook were run on: TypeScript 5.9.3 with Node.js 24.15.0 on Windows 11. Twelve runtime tests and seven expected type rejections pass. A missing union case, weakened optional-property checking and three valid-code runtime faults are detected. No browser, network application or accessibility acceptance is claimed. (2026-09-27).
These workbooks use AI assistance. See how the workbooks are made.
- Everyday typesTypeScript documentation
- Narrowing and discriminated unionsTypeScript documentation
- GenericsTypeScript documentation
- Strict compiler checksTypeScript documentation
- Checked indexed accessTypeScript documentation
- Exact optional property typesTypeScript documentation
- No emission on errorTypeScript documentation
- Migrating from JavaScriptTypeScript documentation
- TypeScript 3.9: expected-error commentsTypeScript documentation
- TypeScript 4.9: satisfies operatorTypeScript documentation
- Node.js 24 test runnerNode.js
- ECMAScript: JSON.parseEcma International, TC39
Created by Mickarle Wagstaff-Irons - Micky Irons with the Mickai team. Published by Mickai LTD. Last updated 27 September 2026.
NextKeep going
Where to go next
Recommended for you
Node.js: build and test a small API
Build a local read-only HTTP API with Node.js: explicit routes, strict query validation, predictable errors and executable tests using built-in modules.
Recommended for you
Test-driven development with an AI coding assistant
Use failing examples to guide an AI coding assistant, test boundary cases, catch deliberate defects and hand over a small Python change with clear evidence.