Free Handbook · Runs in your browser

Problem Solving

Writing the type signature before the body, three patterns worked through with real types, and a full migration of a plain JS function to strict TypeScript.

0 / 105 lessons🔥 0 day streak
ShareXLinkedIn

Module 14 · what you'll be able to do

  • Write a function’s signature before its body, and let the types drive the implementation
  • Recognise two-pointer, sliding-window and discriminated-union-state-machine problems by shape
  • Read a real tsc migration error — TS7006, TS2339, TS2322 — and know exactly what it is asking for
  • Convert an untyped JavaScript function to strict TypeScript end to end
01

Types first: write the signature before the body

In plain JavaScript you often discover a function’s shape while writing its body — what it takes, what it returns, is decided as you go. In TypeScript, write the signature first: the parameter types and the return type, with no implementation yet. This forces the hard questions — what exactly comes in, what exactly goes out, what happens on the empty case — before a single line of logic exists, the same discipline Module 12’s "pin the contract" step taught, now enforced by the compiler instead of by memory.

  1. 1
    Name it and write the signature only

    function groupByLength(words: string[]): Map<number, string[]> { throw new Error("todo") } — no body yet. Just naming the input and output types surfaces the first decision: a Map, or a plain object? Ordered by length, or grouped?

  2. 2
    Read the signature back as a sentence

    "Given an array of strings, return a map from word length to the words of that length." If that sentence does not match what you meant, the signature was wrong before you wrote any logic to get wrong.

  3. 3
    Fill in the body — the types now constrain every line

    The return type Map<number, string[]> means the compiler flags it immediately if you accidentally return {} or forget a return on one branch. The signature is doing the work a comment used to do, except it cannot go stale.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
1, 2, 3
bb, cc
Your turn
Write the signature first for a function that takes orders: { id: number; total: number }[] and returns the id of the highest-total order, or null for an empty array. Write only the signature, read it back as a sentence, then fill in the body.
The signature is a design review with yourself
A public function’s return type locking the contract (Module 00) is not just documentation — writing it first is a five-second design review that catches "wait, what should this return for an empty array?" before that question becomes a production bug reported by someone else.
02

Pattern: two pointers, typed

Two pointers walk from both ends (or two speeds) of sorted data, moving one pointer based on a comparison — O(n) instead of O(n²). The words that give it away: "pair that sums to", "in a sorted array", "from both ends". A typed version is worth writing precisely because the return shape — a tuple, or null for "not found" — is exactly the kind of thing worth pinning down first.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
4,6
true
Your turn
Type and write removeDuplicatesSorted(nums: number[]): number: remove duplicates from a sorted array in place with a write pointer and a read pointer, returning the new length. The signature already tells you it mutates nums and returns a count — write that sentence out before coding.
Quick check

Why is the return type written as [number, number] | null rather than number[] | null?

03

Pattern: sliding window, typed

A sliding window is a contiguous range that expands and shrinks, so each element enters and leaves the window once — O(n) instead of re-scanning. A fixed-size window (sum of every k-length slice) needs only numbers; a variable window that tracks "have I seen this character before, and where" needs a typed Map<string, number> — the key is the character, the value is the last index it was seen at.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
9
3 1
Your turn
Type minSubarrayLength(nums: number[], target: number): number: the length of the shortest contiguous subarray with a sum >= target, or 0 if none exists. Write the signature, then the shrinking-window body.
JuniorIn longestUniqueSubstring, why is lastSeen.get(ch) checked against undefined with !== rather than just written as if (lastSeen.get(ch))?

The values stored are character positions, and position 0 is a perfectly valid, falsy index — if (lastSeen.get(ch)) would silently ignore a character that was last seen at index 0. Comparing explicitly to undefined checks presence, which is what Map<K, V>.get’s V | undefined return type is telling the caller to do.

What they are really testing: The same falsy-zero trap as the Module 13 two-sum question — whether it is recognised as a pattern, not a one-off.

04

Pattern: a discriminated-union state machine

A lot of real UI and network code is "one of a fixed set of states, each carrying different data" — a request that is idle, loading, succeeded with data, or failed with a message. Modelling this as a discriminated union — a union of object types sharing one literal-typed kind field — means a switch on kind narrows the type inside each case, so state.data is only reachable where state.kind === "success" actually holds.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
not started | in flight | got: 42 users | failed: timeout
Your turn
Add a fifth state, { kind: "cancelled"; reason: string }, to the union but do not add a case for it in describe. Paste it into an editor with strict mode and noImplicitReturns — tsc should tell you exactly which case is missing.
The trick that catches a forgotten case
Add a default branch that assigns the (by then fully narrowed) remaining value to a variable typed never: default: { const _exhaustive: never = state; throw new Error("unhandled state") }. If a fifth state is ever added and its case forgotten, that state is no longer narrowed away by the time execution reaches default — so it is no longer assignable to never, and the build fails right there instead of shipping a silent gap.
05

Worked example: migrating a real JS file to TS

This is the walk every real codebase takes. A small, working, untyped JavaScript function — a shopping-cart total — gets renamed .js to .ts, and tsc immediately has opinions. Each error below is exactly what a migration surfaces, in the order it surfaces, ending with the fully typed version.

javascriptcart.js
// cart.js — works fine, no types anywhere
function calculateTotal(items, discount) {
  let total = 0
  for (const item of items) {
    total += item.price * item.qty
  }
  if (discount) {
    total = total - total * discount.rate
  }
  return total
}

const cart = [
  { name: "Keyboard", price: 80, qty: 1 },
  { name: "Mouse", price: 25, qty: 2 },
]

console.log(calculateTotal(cart, { rate: 0.25 }))

Step 1: rename to cart.ts and change nothing else. With strict on, tsc refuses before it even reaches the logic.

Error you will hit

TS7006: items and discount have no declared type

typescript
function calculateTotal(items, discount) {
  let total = 0
  for (const item of items) {
cart.ts:1:26 - error TS7006: Parameter 'items' implicitly has an 'any' type.

1 function calculateTotal(items, discount) {
                           ~~~~~

cart.ts:1:33 - error TS7006: Parameter 'discount' implicitly has an 'any' type.

1 function calculateTotal(items, discount) {
                                  ~~~~~~~~
Why the compiler said that

noImplicitAny (bundled into strict) refuses to silently treat an untyped parameter as any. An any parameter is one tsc cannot check at all, and every call site that passes it stays unchecked too — this is the very first error almost any real migration hits.

The fix

Give each parameter a real type. items is an array of cart lines; discount is optional. Rather than guessing types inline, name the shapes with interfaces.

typescript
interface CartItem {
  name: string
  price: number
  quantity: number
}

interface Discount {
  rate: number
}

function calculateTotal(items: CartItem[], discount?: Discount) {
  // body unchanged for now
}

Step 2: the interface above was typed from memory, and the field was named quantity. The function body — still unchanged from the original JS — reads item.qty.

Error you will hit

TS2339: item.qty does not exist on the new CartItem type

typescript
function calculateTotal(items: CartItem[], discount?: Discount) {
  let total = 0
  for (const item of items) {
    total += item.price * item.qty
  }
cart.ts:14:24 - error TS2339: Property 'qty' does not exist on type 'CartItem'. Did you mean 'quantity'?

14     total += item.price * item.qty
                            ~~~~~~~~
Why the compiler said that

This is the real value of an interface: the body was never wrong before (item.qty is what the JSON, the tests, and every caller actually use), but the interface typed from memory used the wrong name — and the mismatch, which used to become a silent NaN in production, is now a build error.

The fix

Match the interface to how the field is actually used everywhere else in the file, not the other way around.

typescript
interface CartItem {
  name: string
  price: number
  qty: number
}

Step 3: the interface and the body now agree. The last error appears at the call site, where a caller passes a discount that looks reasonable but is not the shape Discount declares.

Error you will hit

TS2322: a string where Discount.rate expects a number

typescript
console.log(calculateTotal(cart, { rate: "25%" }))
cart.ts:22:38 - error TS2322: Type 'string' is not assignable to type 'number'.

22 console.log(calculateTotal(cart, { rate: "25%" }))
                                        ~~~~
Why the compiler said that

Discount.rate is a fraction like 0.25, and "25%" is a string — arithmetic on it would silently produce NaN at runtime with no exception thrown anywhere near the mistake, one of the hardest bugs to trace back to its source. TypeScript catches it at the call site, before the code runs even once.

The fix

Pass the numeric fraction the type actually declares.

typescript
console.log(calculateTotal(cart, { rate: 0.25 }))

All three errors fixed, the fully migrated file: two named interfaces, typed parameters, an explicit return type, and the call sites matching what the types promise.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
130
97.5
Your turn
The migrated file still allows calculateTotal(cart, { rate: 1.5 }) — a 150% discount that would make the total negative, which is a business-logic bug, not a type error, because 1.5 really is a valid number. Add a check (not a type) that throws for a rate outside 0..1, and explain out loud why TypeScript could never have caught this one.

Finish the TypeScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.