TypeScript Study Projects
tsc --noEmit must pass with
zero suppressions before you call a project done.
The projects
Six briefs in increasing difficulty. Each names the lessons it depends on — if a concept feels shaky, the lesson link is the remediation path.
Project 1 — Typed config loader
Type concepts: unions and narrowing, interfaces, type guards.
Write loadConfig(raw: unknown): Config that takes arbitrary parsed JSON and returns either a validated Config or a readable list of problems. unknown input is mandatory — no as casts. Validate port ranges, environment names, and URL shapes; make every failure message name the offending key.
Done means: feeding it your own broken configs (missing keys, wrong types, port 99999) produces precise errors, and the happy path type-checks with noUncheckedIndexedAccess on.
Project 2 — Result-based HTTP client
Type concepts: discriminated unions, generics, the Result pattern.
Wrap fetch so callers never see a thrown exception for expected failures: timeouts, non-2xx statuses, malformed bodies. The return type must be Result<T, HttpError>, forcing the caller to handle both branches. Support typed JSON parsing via a generic parameter.
Done means: a caller that forgets the error branch fails to compile, and a deliberate network failure produces your typed error — not an unhandled rejection.
Project 3 — Typed event bus
Type concepts: mapped and template literal types, keyof and indexed access.
Implement createBus<T extends Record<string, unknown[]>>() where on/emit derive event names and payload tuples from T. Emitting "user:created" with the wrong payload arity must be a compile error; so must subscribing to an event that was never declared.
Done means: renaming an event key in T makes the compiler point at every stale subscriber — the refactoring payoff the template-literal lesson promised.
Project 4 — Form validator with discriminated unions
Type concepts: discriminated unions and exhaustiveness, optional properties.
Model validation rules as a union (required, minLength, pattern, oneOf) with a kind discriminator. Write a validateField whose switch is exhaustive — adding a new rule kind must produce a never error until you handle it.
Done means: deleting one case breaks the build with a never diagnostic, and a valid form returns a strongly typed value rather than a bag of strings.
Project 5 — Mini state store
Type concepts: generic constraints and inference, private fields and invariants.
Build createStore<S>(initial: S) with get, update (taking a function (s: S) => S), and subscribe. Listeners receive the new state; unsubscribing must actually unsubscribe. Then add a select<K extends keyof S>(key: K) that only notifies when that slice changes.
Done means: the selector's return type is S[K] — inferred, never annotated — and mutation through the store is impossible because state is exposed as Readonly<S>.
Project 6 — CSV parser with typed rows
Type concepts: everything above plus ambient declarations discipline.
Parse CSV text into Result<Row[], CsvError> where Row is a mapped type derived from a header-description map ({ name: "string", age: "number" } becomes { name: string, age: number }). Cell-level errors must report row and column numbers.
Done means: changing the header description to age: "string" changes the parsed row type — and the compiler catches code that assumed a number. This is the moment type-level programming stops feeling like magic.
Working method
The briefs above assume a way of working; these three habits are part of the assignment.
Red, green, refactor — compiler edition
For every feature, write the calling code first with the types you wish you had, let the checker reject it, then implement until the diagnostics disappear. The compiler's error messages become your TODO list; the design conversation happens at the type level before any logic exists.
Definition of done
A project is done when: tsc --noEmit passes with no @ts-ignore/@ts-expect-error; public functions have explicit return types; at least one test exists per error branch (see the testing lesson); and the README shows one usage example with its inferred types.
Stretch goals
When a project is done, extend it with one lesson you skipped: add branded IDs (advanced types), replace a thrown error with a Result (errors), or extract a reusable generic (generics). Reusing your own code across projects — the store powering the form validator, the client feeding the CSV parser — teaches composition better than any new brief.