What You'll Build
In this exercise you will take a loosely-typed, `any`-ridden JavaScript-style cricket statistics module and migrate it to fully strict TypeScript in disciplined stages — exactly the production task most TypeScript adopters face. You will start from code that compiles under loose settings but hides real bugs, then ratchet strictness one flag at a time: forbid implicit `any`, enable null safety, eliminate every `any` by replacing it with real types, validate untrusted input at the boundary, and finish under full `strict` mode with zero `any` and zero non-null assertions. The point is not the statistics but the migration craft: keeping the module working at every stage, surfacing and fixing the bugs strictness reveals, and turning cosmetic types into genuine coverage. By the end you will have a module that compiles cleanly under full strict mode and a repeatable process you can apply to any real migration — the difference between TypeScript that decorates a codebase and TypeScript that protects it.
Prerequisites
- Understanding of the strict flags from Lesson 25 — especially noImplicitAny and strictNullChecks — since the migration tightens them in sequence.
- Familiarity with the incremental migration path from Lesson 29 to convert and ratchet strictness without breaking the build at each stage.
- Knowledge of unknown and validation from Lesson 23 to replace any at boundaries with checked, narrowed types rather than unsafe casts.
- Comfort with interfaces, unions, and optional properties from Module 1 to give the loosely-typed data real, precise shapes.
- A working strict-capable TypeScript setup from Lesson 06, where you can toggle individual compiler flags between migration stages.
Setup & Project Structure
You will work in a single module, `stats.ts`, and migrate it through stages by toggling compiler flags in `tsconfig.json` between each stage and fixing the errors that appear. You begin with a deliberately loose config so the starting code compiles despite its problems, then tighten flag by flag. Because the whole point is to observe what each strictness level reveals, you will compile with `tsc --noEmit` after each stage and read the errors as a to-do list. The module stays runnable throughout via `node` (after a permissive compile), so you can confirm behaviour is preserved as types tighten.
# Create and enter the project directory
mkdir strict-migration && cd strict-migration
npm init -y
npm install --save-dev typescript
# Start with a LOOSE tsconfig so the legacy code compiles as-is.
npx tsc --init --target ES2020 --module commonjs
# Then in tsconfig.json set (to begin):
# "strict": false,
# "noImplicitAny": false,
# "strictNullChecks": false
# Project layout:
# strict-migration/
# ├── tsconfig.json <-- toggled between stages
# └── stats.ts <-- the module you migrate
touch stats.ts
# After each stage, run the checker as your to-do list:
# npx tsc --noEmitStep 1 — Foundation
Step 1 is the starting point: the loose, `any`-heavy module as it might exist before migration. It compiles under the permissive config but harbours real bugs — implicit `any` parameters, unchecked array access, and a function that can return `undefined` without anyone handling it. You will study this code to identify what is unsafe, because a migration begins by understanding the existing hazards. This is the 'before' snapshot; every later step removes a category of risk from it. Recognising the hidden bugs here — the kind that pass loose compilation and crash in production — is the foundation of appreciating what strictness will buy you.
// stats.ts — Step 1: the LOOSE starting point (compiles, but hides bugs).
// tsconfig: strict=false, noImplicitAny=false, strictNullChecks=false
// Implicit 'any' parameters — no checking on what comes in.
function strikeRate(runs, balls) {
return (runs / balls) * 100; // crashes/NaN if balls is 0 or undefined
}
// 'any' return; unchecked array access; can silently return undefined.
function topScorer(players) {
let best = players[0]; // could be undefined for an empty array
for (const p of players) {
if (p.runs > best.runs) best = p; // crashes if best is undefined
}
return best;
}
// Untrusted input treated as trusted (no validation).
function loadPlayer(raw) {
return { id: raw.id, name: raw.name.toUpperCase() }; // crashes if name missing
}
// These all "work" under loose settings, but each line above can crash at runtime.
console.log(strikeRate(82, 56));
console.log(topScorer([{ runs: 50 }, { runs: 80 }]));
console.log(loadPlayer({ id: 1, name: "kohli" }));Step 2 — Core Logic
Step 2 begins tightening: enable `noImplicitAny`, which flags every parameter and value that silently became `any`, then give each a real type. This is the first ratchet, and it forces you to describe the actual shapes the functions work with — defining interfaces for a player and its stats, and annotating every parameter and return. The module's logic does not change; you are making its contracts explicit. Fixing the `noImplicitAny` errors is a finite, mechanical task that immediately improves autocomplete and catches the first layer of misuse, setting up the more impactful null-safety stage that follows.
// stats.ts — Step 2: enable noImplicitAny; give everything real types.
// tsconfig: noImplicitAny=true (strictNullChecks still false for now)
interface PlayerStats {
runs: number;
balls: number;
}
interface CricketPlayer {
id: number;
name: string;
runs: number;
}
// Parameters and return now explicitly typed — no implicit any.
function strikeRate(runs: number, balls: number): number {
return (runs / balls) * 100; // still has the balls===0 bug; fixed in Step 3
}
function topScorer(players: CricketPlayer[]): CricketPlayer {
let best = players[0]; // strictNullChecks will flag this next
for (const p of players) {
if (p.runs > best.runs) best = p;
}
return best;
}
function loadPlayer(raw: { id: number; name: string }): CricketPlayer {
return { id: raw.id, name: raw.name.toUpperCase(), runs: 0 };
}
console.log(strikeRate(82, 56), topScorer([{ id: 1, name: "Rohit", runs: 80 }]).name);Step 3 — Integration & Enhancement
Step 3 is the pivotal stage: enable `strictNullChecks`, which surfaces the most valuable errors — the places where a value might be `undefined` and is used unsafely. The compiler now flags that `topScorer` can return `undefined` for an empty array and that `players[0]` might be absent, and (with `noUncheckedIndexedAccess`) that array indexing yields `T | undefined`. You fix each by handling the absent case honestly: returning `CricketPlayer | undefined`, guarding the empty array, and validating untrusted input by typing it `unknown` and narrowing it. This stage eliminates the actual crash bugs from Step 1 and is where the migration delivers its biggest safety payoff, turning latent runtime failures into handled cases.
// stats.ts — Step 3: enable strictNullChecks + noUncheckedIndexedAccess.
// Fix the absent-value bugs honestly.
interface CricketPlayer { id: number; name: string; runs: number; }
// Guard division by zero; the signature was always number, now it is honest.
function strikeRate(runs: number, balls: number): number {
if (balls === 0) return 0;
return (runs / balls) * 100;
}
// Honest return type: an empty squad has no top scorer.
function topScorer(players: CricketPlayer[]): CricketPlayer | undefined {
if (players.length === 0) return undefined; // handle the empty case
let best = players[0]; // T | undefined under noUncheckedIndexedAccess
if (best === undefined) return undefined; // narrow it
for (const p of players) {
if (p.runs > best.runs) best = p;
}
return best;
}
// Validate untrusted input at the boundary: unknown -> checked -> typed.
function loadPlayer(raw: unknown): CricketPlayer {
if (
typeof raw !== "object" || raw === null ||
typeof (raw as any).id !== "number" || typeof (raw as any).name !== "string"
) {
throw new Error("Invalid player data");
}
const r = raw as { id: number; name: string };
return { id: r.id, name: r.name.toUpperCase(), runs: 0 };
}
const winner = topScorer([{ id: 1, name: "Rohit", runs: 80 }]);
console.log(winner?.name ?? "none", loadPlayer({ id: 2, name: "kohli" }).name);Step 4 — Testing & Verification
Now enable full `strict: true`, confirm zero remaining `any`, and verify the module compiles cleanly under maximum strictness with `tsc --noEmit`. Then run it with Node to confirm behaviour is preserved — the same inputs produce the same outputs as the loose version, but the crash paths are now handled. A clean strict compile with no `any` and no non-null assertions (`!`) is the proof that the migration delivered genuine coverage, not a cosmetic rename. Verifying both the strict compile and the preserved runtime behaviour confirms the module is now production-grade: safe under strictness and still correct.
# Final tsconfig: full strictness.
# "strict": true,
# "noUncheckedIndexedAccess": true
# 1) Verify it compiles under full strict mode with zero errors.
npx tsc --noEmit
# (silence = success: no implicit any, null safety satisfied, no errors)
# 2) Confirm no 'any' and no non-null assertions remain.
grep -n ": any\|as any\| any\b\|!\." stats.ts || echo "clean: no any / no non-null assertions"
# 3) Run it — behaviour is preserved, but crash paths are now handled.
npx tsc && node stats.js
# Expected output (behaviour unchanged from the loose version, minus the crashes):
# 146.4... Rohit KOHLIWarning: The biggest temptation during a strict migration is to silence `strictNullChecks` errors with the non-null assertion operator (`value!`) instead of handling the absent case — writing `topScorer(players)!.name` to make the error disappear. This reintroduces exactly the crash the compiler was warning about, because `!` asserts a value is present without any runtime check. Treat every `!` as a red flag in a migration; handle the `undefined` case honestly with a guard or a `?? fallback`, or your 'migrated' code crashes in precisely the spots strictness identified.
Extension Challenge: Take the migration further. (1) Add `exactOptionalPropertyTypes` and `noImplicitOverride`, and fix whatever they surface. (2) Replace the hand-written boundary validation in `loadPlayer` with a schema library like Zod, deriving the `CricketPlayer` type from the schema so the validator and type share one source of truth. (3) Add a Vitest suite (from Lesson 28) with both runtime tests for the edge cases (empty array, zero balls, invalid input) and `expectTypeOf` type tests confirming `topScorer` returns `CricketPlayer | undefined`, locking in the migration's gains against regression.
- A strict migration proceeds in stages, tightening one flag at a time while the module keeps compiling, rather than enabling full strict mode all at once.
- Step one is diagnosis: study the loose code to identify the latent bugs — implicit any, unchecked access, possibly-undefined returns — that permissive compilation hides.
- Enabling noImplicitAny is the first ratchet, forcing explicit types onto every parameter and return without changing the logic, improving autocomplete and catching misuse.
- Enabling strictNullChecks is the pivotal stage, surfacing the actual crash bugs where a value may be undefined, fixed by handling each absent case honestly.
- Untrusted input is migrated by typing it unknown and validating it into a real type at the boundary, rather than trusting it or casting it unsafely.
- A clean full-strict compile with zero any and zero non-null assertions is the proof of genuine coverage, distinguishing a real migration from a cosmetic rename.
- The non-null assertion operator is a migration red flag, since it silences a null-safety error without a runtime check and reintroduces the very crash strictness identified.