A practical TypeScript field guide

Refactoringsmall moves · safer change · clearer design

An easy-to-scan guide to the core ideas in Martin Fowler’s Refactoring, with Kent Beck and contributors—translated into modern TypeScript examples and day-to-day engineering habits.

behavior-preserving test-guided TypeScript field guide
01
Optimize for the next change.

The point is not aesthetic perfection. The point is making tomorrow’s feature, fix, or explanation cheaper and safer.

01

The essence

Refactoring changes the structure of code while preserving what users, callers, and tests can observe.

Improve the internal design of working code through a sequence of tiny, behavior-preserving transformations.

It is not a rewrite, feature addition, or bug fix—even though it often makes features and fixes dramatically easier.

01 / MAKE ITWork

Establish the behavior and prove the path end-to-end.

02 / MAKE ITClear

Reveal intent, remove duplication, and restore boundaries.

03 / MAKE ITEasy to change

Shape the design around what you now understand.

02

The safe loop

Speed comes from feedback. Keep every step small enough that a failure has only one likely cause.

Protect behavior

Add or locate a test at the observable boundary.

Name one smell

Choose a concrete design problem, not “clean it up.”

Make one move

Extract, rename, move, inline, or encapsulate.

Run checks

Tests, types, lint, and a focused runtime check.

Commit stable code

Create a safe point with a narrow, truthful change.

Repeat

Let the improved design reveal the next move.

If the tests fail, the last move should be small enough to understand or reverse immediately.

Behavior hat

Change what the system does

Add a feature, fix a defect, or change an output.

  • Tests may intentionally change
  • Observable behavior changes
  • Product intent drives the work
Refactoring hat

Change how the code is shaped

Rename, extract, move, simplify, or encapsulate.

  • Existing tests should stay green
  • Behavior remains stable
  • Design intent drives the work
03

Smell → likely move

A smell is a reason to investigate, not a verdict. Search by symptom, then choose the smallest useful response.

14 smells

Long Function

One function contains several conceptual steps or levels of abstraction.

Extract Function · Replace Temp with Query

Duplicated Code

The same knowledge or calculation has multiple representations.

Extract Function · Pull Up Method

Mysterious Name

A reader must inspect implementation or comments to learn what a name means.

Rename Variable · Rename Function · Rename Field

Long Parameter List

Arguments travel together or force callers to know too many details.

Introduce Parameter Object · Preserve Whole Object

Large Class

A class changes for unrelated reasons or hides several distinct concepts.

Extract Class · Extract Superclass

Feature Envy

A function works more with another object’s data than with its own.

Move Function · Extract Function

Data Clumps

The same group of values repeatedly appears together.

Introduce Parameter Object · Extract Class

Primitive Obsession

Strings and numbers carry domain rules that the type system cannot see.

Replace Primitive with Object · Replace Type Code

Repeated Switches

The same type-based branching is copied across the system.

Replace Conditional with Polymorphism

Message Chains

Callers navigate through a long chain of internal object relationships.

Hide Delegate · Move Function

Middle Man

A module mostly forwards calls and no longer adds meaning or protection.

Remove Middle Man · Inline Function

Shotgun Surgery

One business change requires small edits across many unrelated files.

Move Function · Move Field · Inline Class

Mutable Data

Shared state changes from multiple places, making order and ownership unclear.

Encapsulate Variable · Change Reference to Value

Comments as Deodorant

Comments explain confusing structure rather than valuable intent or constraints.

Extract Function · Rename · Introduce Assertion
04

Core moves in TypeScript

Open a card for the before-and-after code. These are small transformations; their leverage comes from repetition.

01

Extract Function

Turn an explainable block into a named concept.

⌄

Extract by purpose, not length. The resulting name should explain why the block exists and keep each function at one level of abstraction.

Before
function printInvoice(invoice: Invoice) {
  let total = 0;
  for (const line of invoice.lines) {
    total += line.quantity * line.price;
  }

  console.log(`Invoice ${invoice.id}`);
  console.log(`Total: ${total}`);
}
After
function invoiceTotal(invoice: Invoice): number {
  return invoice.lines.reduce(
    (sum, line) => sum + line.quantity * line.price,
    0,
  );
}

function printInvoice(invoice: Invoice) {
  console.log(`Invoice ${invoice.id}`);
  console.log(`Total: ${invoiceTotal(invoice)}`);
}
02

Introduce Parameter Object

Turn recurring arguments into a domain concept.

⌄

A parameter object reduces signature churn and creates a natural home for validation and behavior. Avoid using it as an unstructured “options bag.”

Before
function ordersInRange(
  orders: Order[],
  start: Date,
  end: Date,
  timezone: string,
): Order[] {
  // filtering logic
}
After
type DateRange = Readonly<{
  start: Date;
  end: Date;
  timezone: string;
}>;

function ordersInRange(
  orders: readonly Order[],
  range: DateRange,
): Order[] {
  // filtering logic
}
03

Replace Primitive with Object

Make domain rules visible to code and compiler.

⌄

A branded type prevents accidental mixing; a value object can additionally own parsing, normalization, and validation. Choose the lightest form that protects a real invariant.

Before
function sendReceipt(email: string) {
  if (!email.includes("@")) {
    throw new Error("Invalid email");
  }
  // ...
}
After
type Email = string & { readonly __brand: "Email" };

function parseEmail(value: string): Email {
  if (!value.includes("@")) {
    throw new Error("Invalid email");
  }
  return value as Email;
}

function sendReceipt(email: Email) { /* ... */ }
04

Replace Conditional with Polymorphism

Localize behavior that varies by type.

⌄

Use this when the same branching repeats or each branch is substantial. A single exhaustive switch over a discriminated union is often clearer than premature class hierarchies.

Before
function shippingCost(order: Order): number {
  switch (order.shipping) {
    case "standard": return 5;
    case "express": return 15;
    case "pickup": return 0;
  }
}
After
interface ShippingMethod {
  cost(order: Order): number;
}

class ExpressShipping implements ShippingMethod {
  cost(order: Order): number {
    return order.total > 100 ? 8 : 15;
  }
}

const cost = order.shippingMethod.cost(order);
05

Separate Query from Modifier

Make state changes explicit and unsurprising.

⌄

Queries answer questions; commands change state. Separating them improves reuse, testing, caching, and reasoning about side effects.

Before
function findAndNotifyOverdue(
  invoices: Invoice[],
): Invoice[] {
  const overdue = invoices.filter(isOverdue);
  overdue.forEach(sendReminder);
  return overdue;
}
After
function findOverdue(
  invoices: readonly Invoice[],
): Invoice[] {
  return invoices.filter(isOverdue);
}

function notifyOverdue(invoices: Invoice[]): void {
  invoices.forEach(sendReminder);
}
05

A TypeScript lens

Types are a powerful refactoring assistant, but passing the compiler is not the same as preserving runtime behavior.

Use the compiler

Let types map the blast radius

Rename symbols through language tooling, tighten a signature, then follow errors to every affected caller. Compiler errors are a work queue—not proof of correctness.

Make states legal

Prefer discriminated unions

Replace loose booleans and optional fields with explicit variants. Impossible combinations should be difficult or impossible to construct.

Control mutation

Reach for readonly

Use readonly parameters and return fresh values when mutation is not part of the contract. This narrows the places where state can change unexpectedly.

Protect boundaries

Parse unknown input once

Keep unknown at system boundaries. Validate and convert it into trusted domain types before business logic touches it.

Avoid type theater

Don’t hide uncertainty with as

A cast silences the compiler without improving the design. Prefer type guards, parsers, constructors, or better data flow.

Keep feedback layered

Types + tests + runtime checks

tsc --noEmit catches structural mismatches. Tests protect behavior. A focused runtime check catches integration assumptions neither can see.

06

A worked refactor

A realistic sequence matters more than a dramatic before/after. Here is how to improve a pricing function without mixing in a pricing change.

Scenario

Untangling checkout pricing

A checkout function calculates a subtotal, applies two discounts, adds shipping, and formats the result. We need to add a loyalty rule next—but first, make the existing rules visible.

Protect

Write characterization tests at the public boundary

Cover a normal cart, the free-shipping threshold, the coupon path, and their boundary values. Assert the current totals—even if the implementation looks odd.

expect(checkoutTotal(cartAtThreshold, noCoupon)).toBe(100);
expect(checkoutTotal(cartBelowThreshold, noCoupon)).toBe(104.99);
Extract

Name the calculations without changing them

Extract subtotal, couponDiscount, and shippingCost. Keep the original order of operations and numeric behavior exactly as they are.

const beforeShipping = subtotal(cart) - couponDiscount(cart, coupon);
return beforeShipping + shippingCost(beforeShipping);
Clarify

Introduce the missing concepts

Replace the coupon string and scattered percentages with Coupon and Money concepts only if they protect real invariants. Rename threshold constants by business meaning.

Verify

Run the feedback stack after every move

Execute the focused tests, typecheck, lint the touched files, and compare a few real checkout scenarios. Commit the structural change separately.

Feature

Switch hats and add the loyalty rule

Now the feature becomes a small, explicit change. Add new tests for the loyalty behavior, then implement it in the place the refactoring revealed.

07

When to refactor

Refactoring is usually woven into product work. Its value is highest immediately before the design would otherwise slow down a real change.

Refactor when it makes a concrete change easier—not merely because code looks old.

Good moments include preparing for a feature, exposing a bug, removing the third repetition, or clarifying code during review.

FIRST TIMEImplement it

Choose the simplest clear solution. Do not predict every future variation.

SECOND TIMENotice it

Compare the similarities and differences. Duplication may still be accidental.

THIRD TIMERefactor it

The repeated knowledge is now concrete enough to extract responsibly.

Good signal

The next change is fighting the design

Refactor toward a named, tested seam that makes the intended change local.

Pause when

The payoff is vague or the behavior is exposed

Add coverage, narrow the target, or leave stable code alone. “Cleaner” is not a sufficient business case.

08

Decision guide

Use this lightweight sequence when you feel the urge to reorganize code.

Is this code difficult to change or understand?If no, leave it alone.
What observable behavior must remain stable?Name the contract before touching the structure.
Not protected?Add characterization tests.
Protected?Proceed narrowly.
Can you name the smell and intended improvement?If not, improve names and extract concepts until you can.
Make one small transformation → verify → repeatStop when the next change becomes straightforward.
09

Pocket checklist

A compact routine for real pull requests and pairing sessions.

Before

  • Define unchanged behavior
  • Locate or add focused tests
  • Name the specific smell
  • State how the next change improves
  • Check for unrelated work in scope

During

  • Make one conceptual move
  • Keep the code runnable
  • Run tests and types frequently
  • Separate structure from behavior
  • Prefer names over explanations

After

  • Confirm behavior is unchanged
  • Check responsibility placement
  • Look for moved—not removed—duplication
  • Compare complexity before and after
  • Commit a narrow, truthful change

The best design makes a business change local.

When one rule changes, one clear place should usually change with it. That is the practical measure of maintainability.