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.
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
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
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
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
functiongroupByLength(words: string[]):Map<number, string[]>{const groups =newMap<number, string[]>()for(const word of words){const key = word.length
const existing = groups.get(key)if(existing) existing.push(word)else groups.set(key,[word])}return groups
}const result =groupByLength(["a","bb","cc","ddd"])console.log([...result.keys()].join(", "))console.log(result.get(2)?.join(", "))
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
functionpairWithSum(sorted: number[], target: number):[number, number]|null{let lo =0let hi = sorted.length -1while(lo < hi){const sum = sorted[lo]+ sorted[hi]if(sum === target)return[sorted[lo], sorted[hi]]if(sum < target) lo++else hi--}returnnull}const found =pairWithSum([1,3,4,6,8,11],10)console.log(found ? found.join(","):"none")console.log(pairWithSum([1,3,4,6,8,11],100)===null)
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?
number[] allows any length, including zero, one, or five numbers. [number, number] tells both the reader and the compiler this is always exactly a pair — destructuring it later is checked against exactly two positions.
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
functionmaxSumWindow(nums: number[], k: number): number {let windowSum =0for(let i =0; i < k; i++) windowSum += nums[i]let best = windowSum
for(let i = k; i < nums.length; i++){
windowSum += nums[i]- nums[i - k]
best =Math.max(best, windowSum)}return best
}console.log(maxSumWindow([2,1,5,1,3,2],3))functionlongestUniqueSubstring(text: string): number {const lastSeen =newMap<string, number>()let left =0let best =0for(let right =0; right < text.length; right++){const ch = text[right]const seenAt = lastSeen.get(ch)if(seenAt !==undefined&& seenAt >= left) left = seenAt +1
lastSeen.set(ch, right)
best =Math.max(best, right - left +1)}return best
}console.log(longestUniqueSubstring("abcabcbb"),longestUniqueSubstring("bbbb"))
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.
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
123456789101112131415161718
// cart.js — works fine, no types anywherefunctioncalculateTotal(items, discount){let total =0for(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
123
functioncalculateTotal(items, discount){let total =0for(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
12345678910111213
interface CartItem {
name: string
price: number
quantity: number
}interface Discount {
rate: number
}functioncalculateTotal(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
12345
functioncalculateTotal(items: CartItem[], discount?: Discount){let total =0for(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
12345
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
1
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
1
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
interface CartItem {
name: string
price: number
qty: number
}interface Discount {
rate: number
}functioncalculateTotal(items: CartItem[], discount?: Discount): number {let total =0for(const item of items){
total += item.price * item.qty
}if(discount){
total = total - total * discount.rate
}return total
}const cart: CartItem[]=[{ name:"Keyboard", price:80, qty:1},{ name:"Mouse", price:25, qty:2},]console.log(calculateTotal(cart))console.log(calculateTotal(cart,{ rate:0.25}))
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.