The Night the Deploy Broke —
A Story About Testing, Types & Quality.
It was 2:47 AM. The pager went off. Nadia's payment service — 14,000 lines of JavaScript, zero tests — had just charged 47 customers twice. This is her story, and every lesson she learned about testing, TypeScript, and code quality in the 72 hours that followed.
00 · Prologue — 02:47 AM
The Slack notification was one line: "we're seeing double charges on the payment dashboard". Three minutes later: "~50 users affected". Three minutes after that: "customer support inbox is on fire".
Nadia opened her laptop in the dark. She'd joined this startup eight months ago. She'd shipped the payment retry logic herself. It had "worked fine in staging" and "worked fine for two months in production". Tonight, at 02:47 AM, it had decided to not work fine.
The retry loop she'd written was supposed to re-attempt failed charges. But it had a subtle bug: when the upstream bank API returned a 502 gateway timeout — meaning "we don't know if the charge went through" — her code treated it like a failure and retried. Sometimes the charge had gone through. The retry charged the customer again.
The fix, once she found it at 04:19 AM, was three lines of code. But finding it had cost the company $18,000 in refunds, a very awkward all-hands meeting, and Nadia's weekend.
This is the story of what Nadia learned in the 72 hours that followed — and what you can learn now, without the 2 AM pager going off.
If you've written JavaScript for a while, you've probably had a night like Nadia's. Maybe not payment systems, maybe something smaller. But the feeling is universal: "if only I had a test for this".
Part 6 is about those tests. And TypeScript. And every tool in between that turns JavaScript from "it works on my machine" into "it works, forever, for everyone".
How to read this article: Every chapter is a scene from Nadia's week. Every technical concept arrives just when she needs it. By the end, you'll have learned testing, TypeScript, and code quality the way a senior engineer learns them — in the flow of solving real problems.
01 · The Cost of No Tests
Day 1, 10:00 AM — the post-mortem. Twelve people on a video call. Leadership wanted answers.
"How do we know this won't happen again?" asked the CTO. Nadia opened her mouth to answer, and then realized she didn't have one. She didn't know. She hoped. Hope was not a good enough answer.
The engineering manager spoke up: "We need automated tests before we touch that module again." Nadia nodded. Everyone nodded. Nobody wanted to be the one to say they'd never written a JavaScript test.
If you've never written a test in JavaScript — welcome to a very large club. Most backend developers who "do JavaScript on the side" have skipped this entirely. And the reason is usually one of five excuses:
"I don't have time."
You don't have time now. You'll have far less time at 2 AM when a payment service is failing.
"My code is too simple to test."
Simple code is the easiest to test. If it's too simple to test, it's too simple to break — so write the test in 30 seconds.
"Testing JS is complicated."
It was in 2015. Today, Vitest makes it a 4-line setup. Modern tooling has eliminated 90% of the friction.
"Tests slow down deployment."
Tests protect deployment. Every minute of tests saves hours of incident response.
"I write correct code."
You think you do. Nadia thought she did too. So does every engineer who ships a bug at 2 AM.
"My team doesn't require it."
Your team will require it the day after your first incident. Get ahead of that day.
What a Test Actually Is
A test is a small piece of JavaScript that calls your code and checks what happened. That's the entire concept. The sophistication is in the details — but the core idea fits in one sentence:
What Tests Actually Buy You
Real, measurable returns — not abstract "quality" platitudes:
| Benefit | What It Means in Practice |
|---|---|
| Confidence to refactor | You can rewrite internals without fear — the tests will catch any behavioral change. |
| Living documentation | A test file shows exactly what the code is supposed to do. Better than comments. |
| Faster debugging | When a test fails, you know exactly which function broke. No more "it worked yesterday". |
| Design pressure | Untestable code is usually badly designed code. Testing pushes you toward better structure. |
| Regression prevention | Every bug you fix gets a test. That bug can never silently return. |
| Async sanity | Async bugs are the worst to reproduce manually. Tests reproduce them in milliseconds. |
| Team onboarding | New engineers run the tests to understand behavior — no oral tradition required. |
| Sleep at night | You can merge on Friday afternoon without a 2 AM pager. |
The economics that changed everything: A bug that reaches production costs 100× more to fix than one caught in a test. A bug that reaches a customer costs 1000×. A bug that charges a customer twice costs $18,000 plus a very awkward meeting. Tests are the cheapest insurance you can buy in software.
02 · The Test Pyramid — Simplified
Day 1, 2:00 PM — Nadia's desk. She pulled up a note she'd saved months ago: "the test pyramid". She had no idea what it meant at the time. Now she needed to.
Her mentor, Anwar, had sent her a Slack message: "Don't start with end-to-end tests. Start with the bottom. You'll get 10× the value for 10% of the effort."
The "test pyramid" is a heuristic (originally from Mike Cohn) that tells you roughly how many of each kind of test to write. The metaphor is simple: many small fast tests at the base, fewer medium tests in the middle, a handful of expensive end-to-end tests at the top.
What Each Layer Tests
| Layer | Tests | Speed | Example |
|---|---|---|---|
| Unit | One function or one small module, in isolation | Milliseconds | calculateTax(order) returns correct value |
| Integration | Several modules together — service + DB, service + external API | Hundreds of ms | The order endpoint writes to DB and returns the right JSON |
| E2E | The entire system, as a user would experience it | Seconds to minutes | Login → add to cart → pay → see confirmation page |
Why the shape matters:
Unit tests are cheap to write, fast to run, and easy to make reliable. E2E tests are the opposite — slow, flaky, expensive. If you invert the pyramid (many E2E, few unit), your CI takes 45 minutes and fails randomly. If you get it right, CI runs in 2 minutes and you trust every result.
What Nadia Decided
She didn't need to write all three layers. She needed to write the right tests for her payment retry logic. Specifically:
- Unit: "given a 502 error, don't retry". This was the fix.
- Unit: "given a network timeout, retry once". This was the original behavior.
- Unit: "given a 4xx client error, don't retry". Sanity check.
- Integration: "the whole retry mechanism, given a mocked bank API, produces the right number of calls".
Four tests. Not forty. This is the trick to testing: you don't test everything. You test the things that break.
03 · Writing Your First Test (Vitest)
Day 2, 9:14 AM. Nadia opened a terminal in her project. She typed
npm install -D vitest, hit enter, and held her breath.
Twelve seconds later, it was installed. She created a file called
retry.test.js next to retry.js, wrote four lines of test code,
and ran npx vitest.
"✓ 4 passed (12ms)" appeared in green.
"Wait, that's it?" she whispered. The whole process — install, write, run — had taken under two minutes. She had put it off for months.
Why Vitest in 2026?
For years, Jest was the default. It's still excellent. But Vitest — built on top of Vite — has become the modern choice for a few concrete reasons:
Native ESM support
Zero config for ES modules. Jest needs transforms. Vitest just works.
Blazing fast
Reuses Vite's transform pipeline. Often 5–10× faster than Jest for large suites.
Jest-compatible API
If you know Jest, you already know Vitest. describe, it, expect — identical.
Beautiful UI
Built-in UI mode with DOM inspector, live reload, and coverage visualization.
Watch mode by default
Only re-runs tests affected by your changes. Save → tests run in <100ms.
Full TypeScript
Type-checked tests out of the box, no ts-jest configuration needed.
But if you're already using Jest, don't rush to switch. Both are excellent. The concepts below work in both.
Setup — Literally Three Steps
# 1. Install Vitest
npm install -D vitest
# 2. (Optional) Add a test script to package.json
# "scripts": { "test": "vitest", "test:run": "vitest run" }
# 3. Run it
npx vitest
Vitest auto-discovers files matching *.test.js, *.spec.js, or
anything under a __tests__/ folder. No config file needed for the basic case.
The Anatomy of a Test
import { describe, it, expect } from 'vitest';
import { shouldRetry } from './retry.js';
describe('shouldRetry', () => {
it('does NOT retry on HTTP 502 (ambiguous — charge may have succeeded)', () => {
// Arrange: set up the input
const error = { status: 502, message: 'Bad Gateway' };
// Act: call the function
const result = shouldRetry(error);
// Assert: check the outcome
expect(result).toBe(false);
});
it('DOES retry on network timeout (safe — no charge was made)', () => {
const error = { code: 'ETIMEDOUT' };
expect(shouldRetry(error)).toBe(true);
});
it('does NOT retry on 4xx client errors (request was wrong)', () => {
const error = { status: 400 };
expect(shouldRetry(error)).toBe(false);
});
});
Notice the three-part rhythm of every good test:
- Arrange — set up the input. (Nadia creates the error object.)
- Act — call the function under test. (Nadia calls
shouldRetry.) - Assert — check the outcome. (Nadia checks it returns the expected boolean.)
This is often called the AAA pattern. It reads naturally, it's easy to review, and every test framework in the world speaks this shape.
Interactive: Watch Vitest Run in Real Time
The Most Common Matchers You'll Actually Use
// Equality
expect(value).toBe(42); // Object.is (strict equality)
expect(value).toEqual({ a: 1 }); // deep structural equality
expect(value).toStrictEqual({ a: 1 }); // strict — also checks undefined props
// Truthiness
expect(value).toBeTruthy(); // any truthy value
expect(value).toBeFalsy(); // any falsy value
expect(value).toBeNull(); // === null
expect(value).toBeUndefined(); // === undefined
expect(value).toBeDefined(); // !== undefined
// Numbers
expect(value).toBeGreaterThan(5);
expect(value).toBeLessThanOrEqual(10);
expect(value).toBeCloseTo(3.14, 2); // floating point — critical!
// Strings & arrays
expect(str).toContain('hello');
expect(str).toMatch(/^\d{4}-\d{2}-\d{2}$/); // regex
expect(arr).toHaveLength(3);
expect(arr).toContainEqual({ id: 1 }); // deep containment
// Objects
expect(obj).toHaveProperty('user.name', 'Alice');
expect(obj).toMatchObject({ user: { name: 'Alice' } }); // partial match
// Errors
expect(() => riskyCall()).toThrow();
expect(() => riskyCall()).toThrow('specific message');
expect(() => riskyCall()).toThrow(/regex pattern/);
expect(() => riskyCall()).toThrowError(CustomError);
// Async — the ones that trip people up
await expect(asyncFn()).resolves.toBe('ok');
await expect(asyncFn()).rejects.toThrow('oops');
The #1 async testing mistake: forgetting to await the
assertion. If you write expect(asyncFn()).resolves.toBe('ok') without
await, the test always passes — even if the assertion would fail. Vitest will
warn you, but only if you know what to look for.
Testing Async Code — The Two Patterns
// Pattern 1: async/await (recommended — most readable)
it('fetches user by id', async () => {
const user = await fetchUser(42);
expect(user.name).toBe('Alice');
});
// Pattern 2: resolves/rejects matchers (concise)
it('fetches user by id', async () => {
await expect(fetchUser(42)).resolves.toMatchObject({ name: 'Alice' });
});
// Testing error cases
it('throws on missing user', async () => {
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
// ❌ The classic mistake (silently passes on failure!)
it('BROKEN TEST', () => {
expect(fetchUser(42)).resolves.toBe('wrong'); // no await!
});
// This test always "passes" because the assertion promise isn't awaited.
// Vitest will warn about this — always read the warnings.
04 · Test Doubles: Mocks, Spies, Stubs, Fakes
Day 2, 11:30 AM. Nadia's second test needed to call the real bank API. Obviously that was a bad idea — she couldn't charge real customers in a test.
Anwar sent her a message: "You need test doubles. Think of them as stunt doubles for your dependencies. The real bank API is the star; the test double is the person who does the dangerous scenes without anyone getting hurt."
Test doubles are substitutes for the real dependencies your code uses — databases, HTTP clients, clocks, file systems. The term comes from the movie industry: a stunt double does the dangerous scenes so the real actor doesn't have to. Same idea.
The Four Kinds of Test Doubles
| Type | What It Does | When to Use |
|---|---|---|
| Stub | Returns a fixed value | "Pretend the DB returns this user" |
| Spy | Records how it was called | "Did we call the logger?" |
| Mock | Programmed with expectations | "The DB must be called exactly once" |
| Fake | Working simplified implementation | In-memory database for tests |
Nadia's Test — With Doubles
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { chargeCustomer } from './payment.js';
describe('chargeCustomer', () => {
let mockBank;
beforeEach(() => {
// Fresh mock for each test — prevents state leaking between tests
mockBank = {
createCharge: vi.fn() // vi.fn() creates a spy/stub combo
};
});
it('calls the bank API once with the correct amount', async () => {
// ARRANGE: stub the response
mockBank.createCharge.mockResolvedValue({ id: 'ch_123', status: 'succeeded' });
// ACT
const result = await chargeCustomer(mockBank, {
customerId: 'cus_abc',
amountCents: 5000
});
// ASSERT: check the outcome
expect(result.status).toBe('succeeded');
// ASSERT: verify how the dependency was called (spy behavior)
expect(mockBank.createCharge).toHaveBeenCalledTimes(1);
expect(mockBank.createCharge).toHaveBeenCalledWith({
customerId: 'cus_abc',
amount: 5000,
currency: 'usd'
});
});
it('DOES NOT retry on 502 (the fix for the incident!)', async () => {
// ARRANGE: bank fails with 502 — ambiguous outcome
mockBank.createCharge.mockRejectedValue(
Object.assign(new Error('Bad Gateway'), { status: 502 })
);
// ACT + ASSERT
await expect(
chargeCustomer(mockBank, { customerId: 'cus_abc', amountCents: 5000 })
).rejects.toThrow();
// 🎯 THE CRITICAL ASSERTION — this is what the incident taught her
expect(mockBank.createCharge).toHaveBeenCalledTimes(1);
// Not 2. Not 3. Exactly ONE call.
// If the bug returns, this test fails immediately.
});
it('DOES retry on timeout (safe to retry)', async () => {
// First call times out, second call succeeds
mockBank.createCharge
.mockRejectedValueOnce(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }))
.mockResolvedValueOnce({ id: 'ch_124', status: 'succeeded' });
const result = await chargeCustomer(mockBank, {
customerId: 'cus_abc',
amountCents: 5000
});
expect(result.status).toBe('succeeded');
expect(mockBank.createCharge).toHaveBeenCalledTimes(2);
});
});
The Spy API You'll Use 90% of the Time
const spy = vi.fn();
// Stub the return value
spy.mockReturnValue(42);
spy.mockResolvedValue({ id: 1 });
spy.mockRejectedValue(new Error('oops'));
// Per-call stubs — different return value each call
spy.mockReturnValueOnce('first').mockReturnValueOnce('second');
// Custom implementation
spy.mockImplementation((x) => x * 2);
// Assertions
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(3);
expect(spy).toHaveBeenCalledWith('arg1', 'arg2');
expect(spy).toHaveBeenLastCalledWith('arg3');
expect(spy).toHaveBeenNthCalledWith(2, 'arg2');
// Inspect calls
spy.mock.calls; // array of argument arrays
spy.mock.calls[0]; // first call's arguments
spy.mock.results; // array of return values
// Reset between tests
spy.mockClear(); // clears call history, keeps impl
spy.mockReset(); // clears history + impl
spy.mockRestore(); // restores original (for spies on real functions)
Spying on Real Objects (Without Rewriting)
import * as logger from './logger.js';
// Wrap an existing method — keeps original behavior but records calls
const spy = vi.spyOn(logger, 'error');
someFunctionThatShouldLog();
expect(spy).toHaveBeenCalledWith('Something went wrong');
// Override the behavior if needed
spy.mockImplementation(() => {}); // silence during test
// ALWAYS restore — critical for tests that share modules
spy.mockRestore();
// Or use automatic cleanup in beforeEach/afterEach:
afterEach(() => {
vi.restoreAllMocks();
});
Interactive: Spies in Action
The rule that saves hours of debugging: always restore or reset
spies between tests. A leaked spy from test A can make test B pass or fail for
mysterious reasons. Use afterEach(() => vi.restoreAllMocks()) as a
default habit.
05 · Coverage — The Truth Nobody Tells You
Day 2, 4:00 PM. Anwar sent Nadia a Slack message: "Did you add coverage reporting?" Nadia replied: "Not yet. What percentage should I aim for?"
Anwar's reply was three lines: "80% is a good target, but it's not the goal. The goal is: would your test suite catch the bug you just fixed? If yes, the number is irrelevant. If no, the number is a lie."
Coverage measures what percentage of your code was executed while your tests ran. There are four kinds, and they mean very different things.
| Type | Measures | Usefulness |
|---|---|---|
| Line coverage | What % of lines ran at least once | Basic — most common |
| Statement coverage | What % of statements ran | Similar to line coverage |
| Branch coverage | What % of if/else/switch paths ran |
More meaningful — catches missed edge cases |
| Function coverage | What % of functions were called | Rough — can be misleading |
Run coverage in Vitest with npx vitest run --coverage. You'll get a report
like this:
% Coverage report from v8
-----------------------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------------------|---------|----------|---------|---------|
All files | 84.21 | 72.50 | 88.00 | 84.21 |
retry.js | 95.00 | 90.00 | 100.00 | 95.00 |
payment.js | 78.00 | 65.00 | 80.00 | 78.00 |
-----------------------------------|---------|----------|---------|---------|
The Truth About Coverage Numbers
100% coverage does NOT mean 100% correct. A test suite can execute every line of your code and still miss every bug. Coverage measures execution, not correctness. Here's a classic example:
// The function under test
function divide(a, b) {
return a / b;
}
// A test with 100% line coverage that proves NOTHING
it('divides two numbers', () => {
divide(10, 2); // calls the function → 100% coverage
// But no assertion! This test passes even if divide() returns garbage.
});
// A test with proper assertions AND edge cases
it('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('handles division by zero', () => {
// Behavior you actually care about — Infinity? Throw? Null?
// The test forces you to DECIDE and DOCUMENT the answer.
expect(divide(10, 0)).toBe(Infinity);
});
Sensible Coverage Targets
| Code Type | Suggested Target | Reasoning |
|---|---|---|
| Payment / money logic | 100% branch | One bug = real financial loss |
| Authentication / authorization | 95%+ branch | Security critical |
| Business rules / state machines | 90%+ branch | Complex logic, many edge cases |
| Data transformations | 80%+ branch | High leverage for unit tests |
| API controllers | 70%+ line | Most behavior lives in services |
| UI formatting / display | No target | Tested by visual regression / E2E |
| Config files | None | Testing config is testing the framework |
Interactive: Branch Coverage Visualizer
06 · Integration & E2E — Testing the Real Thing
Day 2, 7:00 PM. Nadia's unit tests were passing. But she had a nagging
feeling. The unit tests proved that shouldRetry returned the right boolean.
They didn't prove that the whole payment flow actually worked end-to-end.
Anwar, ever the mentor, sent one more message: "Time for integration tests. Spin up the service, mock the bank API, hit the endpoint, and check the DB. This is where the real confidence comes from."
Integration Tests — The Sweet Spot
Integration tests sit between unit and end-to-end. They spin up your service, connect it to a test database (or in-memory fake), and hit it through its real API. The only thing faked is external services.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer } from './server.js';
import { createTestDb } from './test-helpers.js';
describe('POST /charges endpoint', () => {
let server, db, baseUrl;
beforeAll(async () => {
db = await createTestDb(); // in-memory SQLite or Postgres test schema
server = await createServer({
db,
bankClient: makeFakeBankClient() // controlled in-memory fake
});
baseUrl = `http://localhost:${server.port}`;
});
afterAll(async () => {
await server.close();
await db.destroy();
});
it('creates a charge and persists it to the DB', async () => {
const res = await fetch(`${baseUrl}/charges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerId: 'cus_test', amountCents: 5000 })
});
expect(res.status).toBe(201);
const charge = await res.json();
expect(charge.status).toBe('succeeded');
// Verify it was ACTUALLY persisted — not just returned
const stored = await db.query('SELECT * FROM charges WHERE id = ?', [charge.id]);
expect(stored.rows).toHaveLength(1);
expect(stored.rows[0].amount_cents).toBe(5000);
});
it('regression: does NOT double-charge on 502 from bank', async () => {
// Arrange: bank will fail with 502 on first call
await db.query('UPDATE bank_client_config SET fail_next = $1', ['502']);
const res = await fetch(`${baseUrl}/charges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerId: 'cus_test2', amountCents: 3000 })
});
expect(res.status).toBe(502);
// THE KEY ASSERTION — only ONE charge row exists for this customer
const charges = await db.query(
'SELECT * FROM charges WHERE customer_id = $1',
['cus_test2']
);
expect(charges.rows).toHaveLength(1);
// If the incident bug returns, this test fails. Forever. In CI.
});
});
End-to-End with Playwright — The Final Gate
E2E tests are the most expensive and the most valuable. They spin up the entire system (or hit staging), open a real browser, and simulate a real user.
import { test, expect } from '@playwright/test';
test.describe('checkout flow', () => {
test('customer can complete a purchase', async ({ page }) => {
// 1. Login
await page.goto('/login');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'testpass123');
await page.click('button[type=submit]');
// 2. Add product to cart
await expect(page).toHaveURL('/dashboard');
await page.click('[data-testid=add-to-cart]');
// 3. Go to checkout
await page.click('[data-testid=cart-icon]');
await page.click('[data-testid=checkout-button]');
// 4. Fill payment
await page.fill('[name=cardNumber]', '4242424242424242');
await page.fill('[name=cardExpiry]', '12/30');
await page.fill('[name=cardCvc]', '123');
// 5. Confirm
await page.click('[data-testid=confirm-order]');
// 6. Assert success
await expect(page.locator('[data-testid=order-confirmation]')).toBeVisible();
await expect(page.locator('[data-testid=order-status]')).toHaveText('Confirmed');
});
test('regression: shows error on bank 502 (no double charge)', async ({ page }) => {
// Force the fake bank to return 502 on next charge
await page.request.post('/api/test/force-bank-response', { data: { status: 502 } });
// ...login, add to cart, checkout...
await expect(page.locator('[data-testid=error-message]')).toContainText('Payment could not be confirmed');
// Verify only one charge exists
const res = await page.request.get('/api/test/charges?customer=test@example.com');
const charges = (await res.json()).charges;
expect(charges).toHaveLength(1);
});
});
Playwright vs Cypress vs Selenium:
Playwright — modern, fast, great DX, auto-waits, works with Chromium/Firefox/WebKit. The 2026 default.
Cypress — still excellent, especially for component testing. Slightly slower and less flexible.
Selenium — legacy but universal. Use if you have infrastructure reasons.
Playwright is what Anwar recommended to Nadia, and it's what most new projects choose today.
07 · TypeScript — Types as Documentation
Day 3, 9:00 AM. Anwar sat down next to Nadia. "Look at this bug you
fixed," he said, pointing at the screen. "Here — the error object. You check
error.status for 502. But the bank sometimes returns
error.statusCode, right?"
Nadia froze. She hadn't thought of that. If the bank had returned
statusCode: 502 instead of status: 502, her code would have
retried — and double-charged again. The bug wasn't fully fixed.
"This," Anwar said, "is why we use TypeScript. With types, the compiler would have told you. Without types, only a customer tells you."
Why TypeScript Matters for Backend Developers
You've already used static types in Java, C#, or Go. You know the value. TypeScript gives JavaScript the same safety net — but with a design philosophy that's opt-in and zero-runtime (types disappear at compile time).
Catches bugs at compile time
Typos, missing fields, wrong argument types — all found before you run code.
Documentation that can't lie
Types describe what a function expects and returns. They can't drift out of sync.
Autocomplete that's actually useful
Your editor knows the shape of every object. Refactoring becomes safe.
Team communication
"What does this function take?" — just look at the signature. No Slack archaeology.
Zero runtime cost
Types vanish at build time. Your shipped JavaScript is identical to hand-written JS.
Incremental adoption
You can add TypeScript to one file at a time. No big-bang rewrite required.
The 5-Minute TypeScript Tour
// Primitives — same as JavaScript + type annotations
let name: string = 'Alice';
let age: number = 30;
let isActive: boolean = true;
// Arrays — two syntaxes, same meaning
const tags: string[] = ['admin', 'user'];
const ids: Array<number> = [1, 2, 3];
// Objects — define the shape once, reuse everywhere
interface User {
id: string;
email: string;
name: string;
age?: number; // optional — may be undefined
readonly createdAt: Date; // cannot be reassigned
}
function greet(user: User): string {
return `Hello, ${user.name}`;
}
// Now TypeScript catches errors for you
greet({ id: '1', email: 'a@b.com', name: 'Alice' }); // ✓
// greet({ id: '1' }); // ✗ missing fields
// greet('Alice'); // ✗ wrong type
// Unions — one of several types
type Status = 'pending' | 'paid' | 'shipped' | 'cancelled';
function statusColor(status: Status): string {
switch (status) {
case 'pending': return 'yellow';
case 'paid': return 'green';
case 'shipped': return 'blue';
case 'cancelled': return 'red';
}
}
// Generics — types as parameters
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstUser = first(users); // firstUser: User | undefined
const firstId = first(ids); // firstId: number | undefined
Nadia's Bug, Caught by TypeScript
The most powerful TypeScript pattern for backend developers: discriminated
unions. Instead of a bag of optional fields ({ status?, statusCode?, code?, message? }),
model your domain as a union of distinct shapes. Then the compiler forces you to handle
every case. This is how you turn "sometimes returns X, sometimes Y" into "the compiler proves
I handled both".
08 · TypeScript Patterns That Pay Off
Basic type annotations are just the start. These are the patterns that change how you think about code.
Pattern 1 — Discriminated Unions (You Just Saw This)
Model states as distinct variants, each with a "kind" or "type" tag. Perfect for API responses, form states, and state machines.
// ❌ Before: a bag of optional fields — impossible to reason about
interface ApiResult {
data?: User;
error?: Error;
loading?: boolean;
}
// Is data defined when error is defined? TS doesn't know.
// ✅ After: a discriminated union — mathematically precise
type ApiResult<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Now the compiler enforces completeness
function render(result: ApiResult<User>) {
switch (result.status) {
case 'idle': return 'Ready';
case 'loading': return 'Loading...';
case 'success': return result.data.name; // ✓ data exists here
case 'error': return result.error.message; // ✓ error exists here
}
// No default needed — TS knows we covered all cases
}
Pattern 2 — Utility Types (Free Wins)
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
passwordHash: string;
}
// Partial — all fields become optional
type UserUpdate = Partial<User>;
// { id?: string; email?: string; name?: string; ... }
// Required — all fields become required
type CompleteUser = Required<User>;
// Pick — subset of fields
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: string; name: string }
// Omit — everything except these fields (great for API responses)
type PublicUser = Omit<User, 'passwordHash'>;
// { id, email, name, createdAt } — no passwordHash
// Record — object with typed keys and values
type UserById = Record<string, User>;
// Readonly — immutable version
type FrozenUser = Readonly<User>;
// Combining them — the classic API pattern
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
type UpdateUserInput = Partial<CreateUserInput>;
type UserResponse = Omit<User, 'passwordHash'>;
Pattern 3 — The Result Type (Error Handling That Scales)
// A Result type forces callers to handle both paths
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const res = await fetch(`/users/${id}`);
if (!res.ok) {
return { ok: false, error: new Error(`HTTP ${res.status}`) };
}
return { ok: true, value: await res.json() };
} catch (err) {
return { ok: false, error: err as Error };
}
}
// Caller is FORCED to handle both cases
const result = await fetchUser('42');
if (result.ok) {
console.log('Got user:', result.value.name);
} else {
console.error('Failed:', result.error.message);
}
// If you forget one branch, TS errors at compile time.
// This is how you make "handle the error" non-optional.
Why Result types matter: In JavaScript, any function can throw, and any caller can forget to catch. Result types move "must handle the error" from a convention to a compiler-enforced rule. It's how Go does it, and it's how you get the same rigor in TypeScript.
Pattern 4 — Zod: Runtime Validation + Type Inference
TypeScript types disappear at runtime. So how do you validate that a JSON payload from an untrusted source matches your types? Zod (or Yup, Valibot, ArkType) gives you runtime validation and static types from a single declaration.
import { z } from 'zod';
// Define the schema ONCE — get runtime validation AND a TS type
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(18).optional(),
role: z.enum(['user', 'admin']).default('user')
});
// Extract the TypeScript type from the schema
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// { email: string; name: string; age?: number; role: 'user' | 'admin' }
// Use in an Express controller
app.post('/users', (req, res) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.issues });
}
// parsed.data is now typed as CreateUserInput — safe to use
const user = await userService.create(parsed.data);
res.status(201).json(user);
});
Why this pattern is transformative: validate once at the boundary of your system (HTTP handlers, queue consumers), and every downstream function can trust the type. No more "is this really a number?" checks scattered through your business logic.
09 · JSDoc — Type Safety Without a Build Step
Day 3, 2:00 PM. Nadia wanted to use TypeScript, but the team had pushed back: "our build pipeline isn't ready" / "we can't rewrite 15,000 lines" / "our CI will break". Anwar smiled. "You don't need to rewrite anything. Try JSDoc first."
JSDoc is comment-based type annotation. You write types in /** */ comments,
and TypeScript — configured with checkJs: true — type-checks your existing
JavaScript without a build step. It's the perfect middle ground for teams that aren't
ready for a full TypeScript migration.
/**
* Charge a customer via the bank API.
*
* @param {Object} options - Charge options
* @param {string} options.customerId - Customer identifier
* @param {number} options.amountCents - Amount in cents (integer)
* @param {string} [options.currency='usd'] - ISO 4217 currency code
* @returns {Promise<{ id: string, status: string, amount: number }>}
* @throws {Error} If the customer ID is invalid
*
* @example
* const charge = await chargeCustomer({
* customerId: 'cus_abc',
* amountCents: 5000
* });
*/
export async function chargeCustomer({
customerId,
amountCents,
currency = 'usd'
}) {
if (!customerId) throw new Error('customerId required');
return bankApi.createCharge({ customerId, amount: amountCents, currency });
}
// Define reusable types
/**
* @typedef {Object} User
* @property {string} id
* @property {string} email
* @property {string} name
* @property {Date} createdAt
*/
// Discriminated unions work too
/**
* @typedef {Object} HttpError
* @property {'http'} kind
* @property {number} status
*/
/**
* @typedef {Object} NetworkError
* @property {'network'} kind
* @property {string} code
*/
/**
* @typedef {HttpError | NetworkError} BankError
*/
/**
* @param {BankError} error
* @returns {boolean}
*/
function shouldRetry(error) {
if (error.kind === 'http') {
return error.status === 502 ? false : error.status >= 500;
}
if (error.kind === 'network') {
return error.code === 'ETIMEDOUT';
}
return false;
}
Setting It Up — 3 Steps, 5 Minutes
{
"compilerOptions": {
"checkJs": true,
"allowJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "node16",
"moduleResolution": "node16"
},
"include": ["src/**/*.js", "src/**/*.test.js"],
"exclude": ["node_modules"]
}
The migration path Nadia chose:
Week 1: Add jsconfig.json, get editor IntelliSense for free.
Week 2: Add // @ts-check at the top of one file. Fix the errors it reveals.
Week 3–4: Convert that file to .ts. Repeat one file at a time.
Month 2: Enable strict: true and see the real bugs the compiler was hiding.
The team never had to "pause for a rewrite". Each step delivered value on its own.
10 · ESLint, Prettier & Biome
Day 3, 5:00 PM. Nadia's code review had 47 comments, 40 of which were style nits: missing semicolons, inconsistent quotes, indentation. "This is a waste of everyone's time," she thought.
Anwar's last message of the day: "Install ESLint and Prettier tonight. Tomorrow, your team will argue about architecture instead of semicolons."
The Division of Labor
| Tool | Responsibility | Example |
|---|---|---|
| Prettier (formatter) | Whitespace, quotes, semicolons, line length | Converts 'hi' to "hi" (or vice versa) |
| ESLint (linter) | Code quality, potential bugs, style rules | Warns on unused variables, missing await, == vs === |
| Biome (all-in-one) | Both — formatter + linter, single tool | Rust-based, 20× faster than ESLint+Prettier |
For a new project in 2026, Biome is arguably the best choice — one tool, one config, one command, blazing speed. But if you're on an existing project with an ESLint setup, don't migrate unless you have a reason.
Prettier — Zero Decisions
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}
Prettier takes a strong stance: no configuration debates. It reformats your entire codebase in one consistent style. Every PR becomes smaller. Reviews focus on logic, not whitespace.
// ❌ Before — inconsistent
const users=fetchUsers( { page:1,size:20 })
if(users.length>0){console.log('found')}
// ✅ After Prettier
const users = fetchUsers({ page: 1, size: 20 });
if (users.length > 0) {
console.log('found');
}
ESLint — Rules That Catch Real Bugs
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
// Catch real bugs
'no-unused-vars': 'error',
'no-undef': 'error',
'no-console': ['warn', { allow: ['error', 'warn'] }],
'eqeqeq': ['error', 'always'], // === over ==
'no-floating-promises': 'error', // await or .catch() every promise
'no-misused-promises': 'error',
'require-await': 'warn',
// Modern syntax
'prefer-const': 'error',
'no-var': 'error',
'prefer-template': 'warn',
'prefer-arrow-callback': 'warn',
// Security
'no-eval': 'error',
'no-implied-eval': 'error',
'no-new-func': 'error'
}
}
);
The rule that would have caught Nadia's bug:
no-floating-promises. Her retry logic had a promise that wasn't properly
awaited — a floating promise. The error happened later, in a place with no context.
With this rule enabled, ESLint would have flagged it at code review.
Biome — The All-in-One Alternative
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn"
}
}
}
}
Install once, run npx biome check --write . — it formats and lints in a fraction
of the time ESLint + Prettier would take. For a 50,000-line codebase: ~2 seconds vs. ~15 seconds.
Integrating with Your Editor
The setup that makes these tools invisible:
1. Install the Prettier (or Biome) extension for VS Code.
2. Enable "editor.formatOnSave": true in settings.
3. Install the ESLint extension and enable "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }.
4. Add a pre-commit hook with husky + lint-staged so nobody commits unformatted code.
After this, you literally cannot write inconsistent code — the editor fixes it before you think about it.
11 · AI That Writes Good Tests
Day 3, 8:00 PM. Nadia had 12 tests. She wanted 40. She was tired. She opened Claude and typed: "Write Vitest tests for this file. Cover all edge cases including error paths. Use AAA pattern."
Thirty seconds later, she had 25 tests. She read them carefully, deleted 8 that were trivial, fixed 3 that had wrong expectations, and committed 14. The whole process took 20 minutes.
Two years ago, that would have been 4 hours of work. Now it was 20 minutes plus review.
AI is genuinely excellent at generating test scaffolds. But the developers who get the most out of it are the ones who review the output carefully and know what to fix.
Prompts That Produce Useful Tests
Happy path + edge cases
"Write Vitest tests for this function. Cover: normal input, empty input, null, undefined, boundary values, and one happy-path async case."
Error path coverage
"Write tests for every throw/reject path in this function. Include cases where a dependency rejects and where it throws synchronously."
Regression test from a bug
"Here is a bug report and the fix. Write a regression test that would have caught the original bug."
Mock scaffolding
"Generate Vitest mocks for this Express handler. Mock the DB, the logger, and the payment client. Show the full test file."
Coverage gaps
"Here is my source and my tests. List the branches and edge cases I'm not testing."
Test refactoring
"Refactor these tests to reduce duplication. Extract shared setup into helper functions. Keep AAA pattern visible."
What AI Gets Wrong (and How to Catch It)
❌ Common AI test mistakes
- Writing tests that always pass (no real assertions).
- Testing implementation details instead of behavior.
- Missing
awaiton async assertions. - Using stale mocks across tests without resetting.
- Asserting on internal state instead of observable behavior.
- Mocking the thing you're supposed to be testing.
- Not covering error paths — only the happy path.
- Repeating the same test 10 times with tiny variations.
✅ Always check for
- Every test has at least one meaningful assertion.
- Tests fail when you intentionally break the source.
- Mocks reset between tests (
beforeEach). - Async tests are
asyncandawaitproperly. - Error paths tested with
rejects.toThrow(). - Test names describe behavior, not implementation.
- Boundary values: 0, empty string, null, negative numbers.
- Realistic data, not
foo,bar,baz.
The mutation-testing trick: after AI writes tests, deliberately break the source code in 3 places. If the tests still pass, they're not really testing anything. This is called "mutation testing", and doing it manually takes 2 minutes while revealing which tests are actually valuable.
The Full AI Testing Workflow
1. Write the function (or fix the bug)
2. Paste function into AI with this prompt:
"Write Vitest tests for this function. Use AAA pattern.
Cover: happy path, empty input, null, undefined, boundaries,
each error path, and one async rejection case."
3. Review every test:
- Is the assertion meaningful?
- Would this fail if the code was broken?
- Is it testing behavior or implementation?
4. Delete trivial / duplicate tests
5. Add the missing edge cases yourself
(AI often misses domain-specific boundaries)
6. Run the tests
- All pass on current code ✓
- Deliberately break the code → tests fail ✓
7. Commit the ones you trust
Total time for a typical function: 10–20 minutes
(2–3× faster than writing tests by hand)
12 · Resolution — 04:19 AM, 3 Days Later
Three days after the incident. Nadia deployed the fixed version. This time, she wasn't anxious. The deploy button felt different — because the tests had already proven the fix, and 40 other behaviors besides.
She went to sleep at 11 PM. She slept through the night. The pager stayed quiet.
Three weeks later, the team hired two new engineers. They ran the test suite, read the TypeScript types, and understood the payment system in a day — no oral tradition, no Slack archaeology, no "ask Nadia".
The incident cost $18,000. The testing infrastructure cost $0 and a weekend. The math was not close.
What Changed in Nadia's Week
| Before | After |
|---|---|
| Zero tests | 47 tests across unit / integration / E2E |
| Plain JavaScript | TypeScript strict mode, 0 any types |
| Manual code review style debates | ESLint + Prettier enforce everything |
| Deploys on Thursday evening = anxiety | Deploys whenever, with confidence |
| Debugging = reading logs at 2 AM | Debugging = a failing test with a clear error |
| Onboarding new engineers: 2 weeks | Onboarding: 2 days |
| Refactoring felt dangerous | Refactoring is now the safest kind of work |
The Real Lesson
Nadia's week wasn't about becoming a "test expert" or a "TypeScript evangelist". It was about a shift in mindset that happens to every backend developer who crosses this threshold:
What To Do Tomorrow
You don't need to do everything Nadia did in three days. But you can do one thing today:
Pick one function
Any function. Something you wrote recently. Small, but real.
Write one test
Three lines: it('...', () => expect(fn(x)).toBe(y)). That's it.
Install one tool
Vitest OR Prettier OR checkJs: true. Just one, not all three.
Repeat tomorrow
Habits compound. Sixty small steps beat one big rewrite.
13 · Interactive Knowledge Check
Twelve questions covering every tool and pattern from Nadia's story. Each maps to a scenario you'll encounter in your first month of writing production-quality JavaScript.
Part 6 — Testing, TypeScript & Quality Quiz
Twelve questions. Every answer maps to a production scenario.14 · Cheat Sheet & What's Next
Testing, Types & Quality — One-Page Summary
| Concept | One-Line Rule |
|---|---|
| Test pyramid | Many unit tests, fewer integration, fewest E2E. Fast at the base, expensive at the top. |
| AAA pattern | Arrange → Act → Assert. Reads naturally in every test framework. |
| Vitest vs Jest | Vitest for new projects (faster, native ESM). Jest for existing suites. |
| Matchers you'll use 90% of the time | toBe, toEqual, toHaveBeenCalledWith, resolves, rejects. |
| Stub | Returns a fixed value. |
| Spy | Records how it was called. |
| Mock | Has expectations — must be called in a specific way. |
| Fake | Working simplified implementation (in-memory DB). |
| Always reset mocks | afterEach(() => vi.restoreAllMocks()) — prevents leaks between tests. |
| Coverage | Measures execution, not correctness. Target 100% for money, 70% for the rest. |
| Branch coverage | More meaningful than line coverage — catches missed if/else paths. |
| Integration tests | Test several modules together — real DB, real HTTP, mocked externals. |
| Playwright | The 2026 default for E2E. Auto-waits, great DX. |
| TypeScript | Types as documentation. Zero runtime cost. Opt-in by file. |
| Discriminated unions | Model states with a tag field. Compiler enforces completeness. |
| Utility types | Partial, Pick, Omit, Record, Readonly. |
| Result type | { ok: true, value } | { ok: false, error } — forces error handling. |
| Zod | Runtime validation + type inference from one schema. |
| JSDoc | Types without a build step. Enable with checkJs: true. |
| Prettier | Formatting — zero decisions. formatOnSave: true. |
| ESLint | Code quality & bug-catching rules. Especially no-floating-promises. |
| Biome | All-in-one formatter + linter. Rust-based, 20× faster. |
| AI for tests | Excellent scaffold generator. Always review, then break source to verify. |
Do / Don't — Quality Edition
✅ DO
- Write tests when you fix a bug — non-negotiable.
- Start with unit tests, not E2E.
- Use
beforeEachto reset state and mocks. - Enable
checkJsor migrate to TypeScript incrementally. - Model state as discriminated unions, not optional fields.
- Validate input at system boundaries with Zod (or similar).
- Format on save — never debate style in code review.
- Enable
no-floating-promisesin ESLint. - Use AI to scaffold tests, then review every assertion.
- Deliberately break source to verify your tests catch it.
❌ DON'T
- Don't aim for 100% coverage as a goal.
- Don't test implementation details — test behavior.
- Don't forget
awaiton async assertions. - Don't share mock state between tests.
- Don't use
anyin TypeScript — useunknown+ narrow. - Don't add types everywhere at once — migrate file by file.
- Don't skip error path tests.
- Don't trust AI tests without verification.
- Don't argue about tabs vs spaces — automate it.
- Don't skip pre-commit hooks — they save CI time.
What's Coming in Part 7 — The Final Chapter
Part 7 closes the series with everything you need to ship real JavaScript to production:
- AI-assisted development workflows — how senior engineers use Claude, Copilot, and Cursor.
- Observability: structured logging, tracing, and metrics for JavaScript services.
- Performance profiling in production — where JavaScript actually slows down.
- Deployment strategies: blue-green, canary, feature flags.
- Security best practices: input validation, secrets, CSP, CORS.
- Building your personal "JS expert" toolkit — the reading list, tools, and habits.
- A complete capstone project that ties together everything from Parts 1–6.
- Where to go next: WebAssembly, edge runtime, Bun, Deno, and the future of JavaScript.
Practice before Part 7: pick the file in your codebase that has
caused the most production headaches. Write three tests for it — one happy path,
one edge case, one regression test for a bug you remember. Then add // @ts-check
at the top of the file and fix the first error the compiler gives you.
You'll be shocked how much you learn in 30 minutes.
Part 6 of 7 · JavaScript for Backend Developers · FreeLearning365.com
🌟 Continue Learning on FreeLearning365
Free tools, tutorials, and question banks for developers, students, and professionals.
- Learn Free ProgrammingJavaScript, Angular, Python, SQL, Data Analysis & More
- 100+ Free Online ToolsDevelopers, SEO Specialists & Daily Tasks
- Professional IT TrainingAdvance Your Career with Hands-On Courses
- AI Prompt Generator40+ Professional Prompt Types
- Drag & Drop Form GeneratorBootstrap 5.3/4, Custom CSS, Grid Layout
- Income Tax CalculatorNBR Slabs, Rebate & Minimum Tax
- NPS 2026 Salary Calculatorবাংলাদেশ জাতীয় বেতন স্কেল ২০২৬
- Electricity Bill CalculatorBERC Tariff & Appliance Report
- eBook CollectionFree for Download
- BCS / HSC / SSC Question Bankবাংলাদেশের সর্ববৃহৎ ফ্রি প্রশ্ন ব্যাংক
- AI Background RemoverRemove Image Background Free
- Free QR Code GeneratorCreate Custom QR Codes Online
- Barcode & Label GeneratorCustom Barcodes, QR Codes, A4 Sheets
- EV Class 9-10 All SubjectsPhysics, Chemistry, Biology, Math, ICT & BGS
- Our ServicesFull IT Solutions & Training
- Job Interview PreparationProgramming, Cloud, Data, ERP & More

No comments:
Post a Comment
Thanks for your valuable comment...........
Md. Mominul Islam