What You Are Building
The project is a task manager, deliberately small enough to hold in your head and deliberately structured the way a real feature is structured: domain types at the centre, a validation layer at the boundary, a service that owns the data, and a thin layer that displays it.
Almost every project on the internet types the happy path and stops. This one does the three things that separate a typed project from a safe one. Task state is a discriminated union, so a task cannot be simultaneously done and cancelled. Operations that can fail return a Result instead of throwing or returning null, so the caller cannot forget the failure case. And data entering from outside is unknown until a guard has checked it.
Build it in a single folder with strict on. Type each file yourself before reading the code below — the point is to notice which decisions the type system forces you to make, and that only happens if you make them.
- Domain types with
interface,as constunions andreadonlyfields - A discriminated union for task state, with an exhaustiveness check on every switch
- Derived input types built with
Pick,OmitandPartial - A generic
Result<T>type for operations that can fail - A runtime type guard validating untrusted input at the boundary
- A service class holding state behind a private field
- Strict null handling throughout — no
any, no assertions
- A minimal setup:
npm init -y,npm i -D typescript tsx,npx tsc --init, then set"strict": trueand run files withnpx tsx src/main.ts.
Step 1 — The Domain Types
Start with the shapes, because every other file will be checked against them. Priorities are a fixed list, so they are an as const array with a union derived from it: one definition gives both a runtime list to iterate over and a compile-time type to check against.
The important decision is TaskState. It would be easy to write status: string plus optional completedAt and reason fields, and that shape would permit a task that is todo with a completion date, or cancelled with no reason. Written as a discriminated union, each state carries exactly the data that state needs, and the invalid combinations cannot be constructed at all.
The input types are derived rather than written out. CreateTaskInput picks the fields a caller supplies — the id, the state and the timestamp are the service's business. UpdateTaskInput makes a chosen subset optional. Both update automatically when Task changes, which is the whole point.
Notice TaskState["status"] in the filter. Indexed access on a union gives the union of that property across all members, so the filter's status option stays correct without repeating the four names.
// src/types.ts
export const PRIORITIES = ["low", "medium", "high"] as const;
export type Priority = typeof PRIORITIES[number];
// Each state carries only the data that state can have
export type TaskState =
| { status: "todo" }
| { status: "in-progress"; startedAt: Date }
| { status: "done"; completedAt: Date }
| { status: "cancelled"; reason: string };
export interface Task {
readonly id: string;
readonly createdAt: Date;
title: string;
description?: string;
priority: Priority;
state: TaskState;
}
// Derived, so they cannot drift from Task
export type CreateTaskInput = Pick<Task, "title" | "priority"> & {
description?: string;
};
export type UpdateTaskInput = Partial<Pick<Task, "title" | "description" | "priority">>;
export interface TaskFilter {
status?: TaskState["status"]; // "todo" | "in-progress" | "done" | "cancelled"
priority?: Priority;
search?: string;
}
// A failure that the caller cannot ignore
export type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E }; Resultis the alternative to throwing. An exception is invisible in a function's type, so nothing reminds a caller it exists; aResultforces the caller to checkokbefore reaching the value.
Step 2 — Validation at the Boundary
This file is the one most tutorials skip, and it is where the safety actually comes from. Anything arriving from outside your program — a form submission, a JSON file, a request body — is unknown until proven otherwise. The job here is to turn unknown into CreateTaskInput, or to explain why it cannot be done.
The guard for Priority shows a detail worth remembering: PRIORITIES.includes(value) does not compile when value is a general string, because includes expects one of the three literals. Widening the array to readonly string[] for the check is the standard fix, and the type predicate on the function is what carries the result back out.
The parse function returns a Result rather than throwing, so the caller decides what a bad input means — a validation message in a form, a 400 response in an API, a skipped row in an import. Each failure carries a message that says what was wrong, because "invalid input" helps nobody.
Everything after this file can trust its data. That is the trade: one careful function per entry point, and the rest of the program stops guessing.
// src/validate.ts
import { PRIORITIES } from "./types.js";
import type { Priority, CreateTaskInput, Result } from "./types.js";
export function isPriority(value: unknown): value is Priority {
return (
typeof value === "string" &&
(PRIORITIES as readonly string[]).includes(value)
);
}
export function parseCreateInput(value: unknown): Result<CreateTaskInput> {
if (typeof value !== "object" || value === null) {
return { ok: false, error: "Expected an object" };
}
if (!("title" in value) || typeof value.title !== "string" || value.title.trim() === "") {
return { ok: false, error: "title is required and must be a non-empty string" };
}
if (!("priority" in value) || !isPriority(value.priority)) {
return { ok: false, error: `priority must be one of: ${PRIORITIES.join(", ")}` };
}
const input: CreateTaskInput = {
title: value.title.trim(),
priority: value.priority
};
if ("description" in value && typeof value.description === "string") {
input.description = value.description;
}
return { ok: true, value: input };
} - Once you have written a few of these by hand, look at a schema validation library such as Zod. It generates both the runtime check and the TypeScript type from one description, so the two can never disagree — but write them manually first, so you understand what the library is doing for you.
Step 3 — The Service
The service owns the tasks and is the only thing allowed to change them. The store is a #tasks field, genuinely private at runtime, so no other module can reach in and mutate the map behind the service's back.
create is the only place a Task comes into existence, which is why the id, the creation timestamp and the initial state are set here rather than being accepted from a caller. Making those fields readonly on the interface backs that up: nothing downstream can quietly reassign an id.
The state machine lives in nextState, a plain function separate from the class. It takes the current state and returns either the next one or an explanation of why the move is not allowed — moving a completed task forward is a real error, not something to silently ignore. The never assignment in the default branch is the exhaustiveness check: add a fifth state to TaskState and this function refuses to compile until you have decided what happens to it.
Every operation that can fail returns a Result. Look at advance: when nextState fails, the failure value is simply returned, because a Result failure of one type is already a valid failure of the other. The narrowing does the work with no conversion.
// src/taskService.ts
import type {
Task, TaskState, TaskFilter,
CreateTaskInput, UpdateTaskInput, Result
} from "./types.js";
function nextState(state: TaskState): Result<TaskState> {
switch (state.status) {
case "todo":
return { ok: true, value: { status: "in-progress", startedAt: new Date() } };
case "in-progress":
return { ok: true, value: { status: "done", completedAt: new Date() } };
case "done":
return { ok: false, error: "Task is already done" };
case "cancelled":
return { ok: false, error: `Task was cancelled: ${state.reason}` };
default: {
const exhaustive: never = state;
throw new Error(`Unhandled state: ${JSON.stringify(exhaustive)}`);
}
}
}
export class TaskService {
#tasks = new Map<string, Task>();
create(input: CreateTaskInput): Task {
const task: Task = {
id: crypto.randomUUID(),
createdAt: new Date(),
title: input.title,
description: input.description,
priority: input.priority,
state: { status: "todo" }
};
this.#tasks.set(task.id, task);
return task;
}
update(id: string, changes: UpdateTaskInput): Result<Task> {
const existing = this.#tasks.get(id);
if (existing === undefined) return { ok: false, error: `No task with id ${id}` };
const updated: Task = { ...existing, ...changes };
this.#tasks.set(id, updated);
return { ok: true, value: updated };
}
advance(id: string): Result<Task> {
const task = this.#tasks.get(id);
if (task === undefined) return { ok: false, error: `No task with id ${id}` };
const next = nextState(task.state);
if (!next.ok) return next; // the failure is already the right shape
const updated: Task = { ...task, state: next.value };
this.#tasks.set(id, updated);
return { ok: true, value: updated };
}
find(filter: TaskFilter = {}): Task[] {
return [...this.#tasks.values()].filter(task => {
if (filter.status !== undefined && task.state.status !== filter.status) return false;
if (filter.priority !== undefined && task.priority !== filter.priority) return false;
if (filter.search !== undefined &&
!task.title.toLowerCase().includes(filter.search.toLowerCase())) return false;
return true;
});
}
} - The filter checks use
!== undefinedrather than truthiness. An empty search string is a legitimate value, andif (filter.search)would silently ignore it — the same falsy trap from the type-guards lesson.
Step 4 — Using It
The last file wires everything together, and it is worth reading for what it does not contain. There are no null checks scattered through it, no defensive if statements around fields that might be missing, and no assertions. The types have already removed those cases.
The describe function is the payoff of the discriminated union. Each branch reaches for the field that belongs to that state — startedAt, completedAt, reason — and the compiler permits it only inside the matching case. No branch can accidentally read a field that does not exist there, and the function needs no default because TypeScript can see that every state is covered.
Notice how the Result type shapes the calling code. You cannot reach result.value without first checking result.ok, so the failure path is written by construction rather than by discipline. That is the difference between a type system that documents your intentions and one that enforces them.
// src/main.ts
import { TaskService } from "./taskService.js";
import { parseCreateInput } from "./validate.js";
import type { Task } from "./types.js";
function describe(task: Task): string {
switch (task.state.status) {
case "todo":
return `[ ] ${task.title}`;
case "in-progress":
return `[~] ${task.title} (started ${task.state.startedAt.toISOString()})`;
case "done":
return `[x] ${task.title} (done ${task.state.completedAt.toISOString()})`;
case "cancelled":
return `[-] ${task.title} — ${task.state.reason}`;
}
}
const service = new TaskService();
// Input from outside: unknown until parsed
const raw: unknown = { title: " Revise generics ", priority: "high" };
const parsed = parseCreateInput(raw);
if (!parsed.ok) {
console.error("Rejected:", parsed.error);
} else {
const task = service.create(parsed.value);
console.log(describe(task)); // [ ] Revise generics
const started = service.advance(task.id);
if (started.ok) console.log(describe(started.value));
const done = service.advance(task.id);
if (done.ok) console.log(describe(done.value));
const again = service.advance(task.id);
if (!again.ok) console.log("Cannot advance:", again.error);
}
console.log(service.find({ priority: "high" }).map(describe)); - Try breaking it on purpose. Add
| { status: "blocked"; blockedBy: string }toTaskStateand compile: the errors will land onnextStateand ondescribe, and nowhere else. That short list of exactly the places needing attention is what a well-typed codebase buys you.
Where to Take It Next
The project is complete but deliberately small, and the interesting work is in extending it. Each of the additions below exercises something from earlier in the course, and each one is a realistic next step rather than an exercise for its own sake.
If you want one thing to do first, make the service generic. A Store<T extends { id: string }> with add, get, update and find works for tasks, users and projects alike, and building it will tell you quickly whether generics and constraints have really settled in your head.
After that, the most valuable habit to carry into your own projects is the one this file demonstrates rather than describes: types at the edges, validation at the boundary, unions for anything with states, and no escape hatches unless you can explain why.
- Make the service generic over any entity with an
id, and reuse it for a second type - Add persistence with
localStorageor a JSON file — and validate on the way back in, since stored data is untrusted data - Replace the hand-written guard with a Zod schema and derive the type from it
- Add a
cancel(id, reason)method, and let the exhaustiveness checks show you every place that needs updating - Add due dates and a
Result-returning date parser, so bad input never reaches the service - Wrap it in a React component: the screen state is another discriminated union, and the props are another interface
- Turn on
noUncheckedIndexedAccessand fix what it finds — a short, instructive exercise
- One last reminder to take with you: TypeScript checks what you wrote, not what arrives. Every guarantee in this project rests on the validation in
parseCreateInput. Remove it and the code still compiles, still looks safe, and is not.
