100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
TypeScript Deep Dive
65 minintermediate

Production Practice — Strict-mode Migration

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.

Analogy🏏Cricket
🏏 Think of it like cricket: Picture a team's analytics department building its own reusable toolkit of statistical procedures — a way to compute career averages across any format, a way to trace every delivery in a match down to ball level, a way to filter players by any attribute. Each procedure is written once, carefully, to work on any team's data and to be combined with the others. Just as the analysts build a library of general, composable procedures rather than re-deriving statistics by hand each match, you build a library of general, composable type utilities rather than re-writing type transformations each time. Just as a good procedure is correct, finishes in reasonable time, and works across datasets, a good type utility is correct, terminating, performant, and reusable. Just as the toolkit's real value is that any analyst can apply it to any match, your library's value is that any developer can apply it to any type. This reveals the exercise's spirit: invest in correct, reusable tools once, and reap their leverage everywhere — the analytics department's philosophy and the library author's alike.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: Think of a bowling coach building drills where success is judged not by whether a ball is bowled at all, but by whether it lands on a precise painted spot every time — the target itself is the test. Just as the drills live on one practice strip with the targets painted on, you use one types.ts module holding the utilities with a set of Expect assertions at the bottom. Just as the strictest target discipline is switched on so a delivery a fraction off the spot is counted a miss, strict mode ensures the type-level results are sound and precise. Just as these drills produce no match runs — their whole purpose is landing the ball exactly right — type utilities have no runtime behaviour; their output is purely a type. Just as the coach judges the drill by whether every ball hit its painted mark, you verify by whether every utility produces exactly the expected type. This reveals why the setup is arranged this way: when precision itself is the deliverable, the target assertions are the entire test.
bash
# 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 --noEmit

Step 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Think of how an analytics department first builds its verification rig before trusting any new procedure — a reference dataset with known correct answers, so any new calculation can be checked against the known result immediately. Just as the reference rig lets analysts confirm a new procedure is correct before relying on it, the Equal/Expect harness lets you confirm a new type utility is correct before relying on it. Just as the foundational career-average and ball-tracing procedures are built first because everything else uses them, DeepReadonly and DeepPartial are built first as the recursive backbone. Just as a procedure verified against known answers can be trusted across all future matches, a utility verified by the harness can be trusted across all future types. This reveals why the harness and foundations come first: a verification rig and a few core procedures, established up front, make everything built afterward checkable and dependable.
typescript
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Consider two analytics procedures. One filters the squad to only players matching a criterion — keep only the bowlers, dropping everyone else — exactly as PickByValue keeps only properties whose values match a type and drops the rest. The other traces every possible route through a nested match structure — from tournament down to innings down to each delivery — producing a full list of addressable points, exactly as Paths produces every dotted route through a nested object. Just as the filtering procedure discards non-matching players by leaving them out, PickByValue discards non-matching keys by remapping them to never. Just as the route-tracing procedure recurses down every level of the match to enumerate every reachable point, Paths recurses down every level of the type to enumerate every reachable path. This reveals how the toolkit composes: filtering by criterion and tracing every route are exactly the kinds of deep, systematic operations that mapped filtering and recursive template construction make possible at the type level.
typescript
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Think of how the analytics toolkit's procedures combine into a complete workflow: trace a route to a specific statistic, then look up the actual value at that route, then reformat its label for display, then apply a freeze so the published figure cannot be altered. Each step uses a different procedure, but they chain because they were designed to. Just as the route-tracing and value-lookup procedures are companions — one enumerates paths, the other resolves the value at a path — Paths and Get are companions, one listing dotted paths and the other resolving the type at one. Just as the label-reformatting step transforms a name's format, the CamelToSnake utility transforms a key's format. Just as the analysts chain filtering, lookup, and freezing into one pipeline, you compose PickByValue, Get, and DeepReadonly into combined types. This reveals the payoff of a well-designed toolkit: independent procedures that chain into complete workflows, at the analytics desk and at the type level alike.
typescript
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Think of a bowler's accuracy drill judged entirely by whether every ball lands on its painted target — no batter, no runs, just the marks; if every delivery hits its spot, the drill is passed, full stop. Just as the drill needs no match to prove accuracy, you verify the library with tsc --noEmit, which type-checks every utility and every Expect assertion without producing output. Just as a single ball missing its painted mark fails the drill outright, the harness makes a wrong type result a compile error, so a clean compile is a complete proof that every utility produces exactly the expected type. Just as there are no runs to tally because target-hitting is the whole point, there is no runtime to run — these are type-level functions, so the compile is the test. This reveals why type utilities are verified this way: when the deliverable is a precise type, the compiler landing every assertion is the full and final proof of correctness.
bash
# 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  KOHLI

Warning: 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.
Lesson 30 of 35
0% complete