06 Pragmatic migrations 6 chapters

Theme 01 · Type-Safety Maximalist, But Pragmatic

Pragmatic migrations

Explanation

Pragmatic migrations

Plain Human Explanation

Better types help only if the team can adopt them without freezing product work. A pragmatic migration improves one risky path at a time, keeps old callers working during the transition, and removes escape hatches once the new shape is proven.

Technical Explanation

Introduce safer types at a boundary, adapter, or new code path before changing every caller. Use small compatibility functions, focused tests, and visible deprecation points. Avoid spreading any, broad overloads, or permanent optional fields just to make the migration feel complete on day one.

Why It Matters

  • User impact: safety improvements land without creating a large regression window.
  • Product behavior: old and new paths stay understandable while the team moves toward one model.
  • Risk: big-bang type rewrites create casts, skipped tests, and half-finished states.
  • Decision point: use this when the safer model is correct but the current codebase cannot absorb it all at once.

The Core Move

Pick a narrow entry point, make it safer, and expand from there. A good migration has a clear next step and a clear removal point for compatibility code.

Small Example

Pragmatic migrations: Small Example

Bad TypeScript Example

type Plan = "free" | "pro" | "team";

// Changed everywhere at once, then patched with casts where callers broke.
export function canCreateProject(plan: Plan) {
  return plan !== "free";
}

canCreateProject(localStorage.getItem("plan") as Plan);
type Plan = "free" | "pro" | "team";

// Changed everywhere at once, then patched with casts where callers broke.
export function canCreateProject(
  plan: Plan,
) {
  return plan !== "free";
}

canCreateProject(
  localStorage.getItem("plan") as Plan,
);

Good TypeScript Example

type Plan = "free" | "pro" | "team";

function parsePlan(value: string | null): Plan {
  if (value === "pro" || value === "team") return value;
  return "free";
}

export function canCreateProject(plan: Plan) {
  return plan !== "free";
}

export function canCreateProjectFromStoredPlan(rawPlan: string | null) {
  return canCreateProject(parsePlan(rawPlan));
}
type Plan = "free" | "pro" | "team";

function parsePlan(
  value: string | null,
): Plan {
  if (value === "pro" || value === "team")
    return value;

  return "free";
}

export function canCreateProject(
  plan: Plan,
) {
  return plan !== "free";
}

export function canCreateProjectFromStoredPlan(
  rawPlan: string | null,
) {
  return canCreateProject(
    parsePlan(rawPlan),
  );
}

What Changed

  • The bad version changes the function type but keeps untrusted callers alive with a cast.
  • The good version adds a small adapter for old raw input.
  • The core function gets the safer type now while migration work continues at the edges.

Realistic Example

Pragmatic migrations: Realistic Example

This example migrates profile visibility from loose strings to a typed product model.

Bad TypeScript Example

type Profile = {
  id: string;
  visibility: "public" | "private";
};

type ProfileStore = {
  save(profile: Profile): Promise<void>;
};

export async function updateProfileVisibility(req: any, store: ProfileStore) {
  const profile: Profile = {
    id: req.body.id,
    visibility: req.body.visibility,
  };

  await store.save(profile);
  return profile;
}
type Profile = {
  id: string;
  visibility: "public" | "private";
};

type ProfileStore = {
  save(profile: Profile): Promise<void>;
};

export async function updateProfileVisibility(
  req: any,
  store: ProfileStore,
) {
  const profile: Profile = {
    id: req.body.id,
    visibility: req.body.visibility,
  };

  await store.save(profile);

  return profile;
}

Good TypeScript Example

type ProfileVisibility = "public" | "private";

type Profile = {
  id: string;
  visibility: ProfileVisibility;
};

type ProfileStore = {
  save(profile: Profile): Promise<void>;
};

function parseProfileVisibility(value: unknown): ProfileVisibility | null {
  if (value === "public" || value === "private") return value;
  return null;
}

export async function updateProfileVisibility(
  req: {
    body: Record<string, unknown>;
  },
  store: ProfileStore,
) {
  const visibility = parseProfileVisibility(req.body.visibility);
  if (typeof req.body.id !== "string" || !visibility) {
    return { ok: false, error: "invalid-profile-update" };
  }

  const profile: Profile = { id: req.body.id, visibility };
  await store.save(profile);
  return { ok: true, profile };
}
type ProfileVisibility =
  | "public"
  | "private";

type Profile = {
  id: string;
  visibility: ProfileVisibility;
};

type ProfileStore = {
  save(profile: Profile): Promise<void>;
};

function parseProfileVisibility(
  value: unknown,
): ProfileVisibility | null {
  if (
    value === "public" ||
    value === "private"
  )
    return value;

  return null;
}

export async function updateProfileVisibility(
  req: {
    body: Record<string, unknown>;
  },
  store: ProfileStore,
) {
  const visibility = parseProfileVisibility(
    req.body.visibility,
  );

  if (
    typeof req.body.id !== "string" ||
    !visibility
  ) {
    return {
      ok: false,
      error: "invalid-profile-update",
    };
  }

  const profile: Profile = {
    id: req.body.id,
    visibility,
  };

  await store.save(profile);

  return {
    ok: true,
    profile,
  };
}

What Changed

  • The bad version declares the new type but still trusts the old loose request body.
  • The good version migrates the route boundary first and gives the store a real typed value.
  • Existing callers can be moved one by one by reusing the parser.

System Example

Pragmatic migrations: System Example

At system scale, pragmatic migrations keep safer types from becoming a risky rewrite.

Larger System-Level Bad TypeScript Example

type OrderState = "draft" | "paid" | "shipped" | "cancelled";

type OrderStore = {
  findAll(): Promise<unknown[]>;
  save(order: { id: string; state: OrderState }): Promise<void>;
};

export async function migrateOrders(store: OrderStore) {
  const orders = await store.findAll();

  for (const order of orders as Array<{
    id: string;
    state: OrderState;
  }>) {
    await store.save({ id: order.id, state: order.state });
  }
}

export function shipOrder(order: { id: string; state: OrderState }) {
  return { ...order, state: "shipped" };
}
type OrderState =
  | "draft"
  | "paid"
  | "shipped"
  | "cancelled";

type OrderStore = {
  findAll(): Promise<unknown[]>;
  save(order: {
    id: string;
    state: OrderState;
  }): Promise<void>;
};

export async function migrateOrders(
  store: OrderStore,
) {
  const orders = await store.findAll();

  for (const order of orders as Array<{
    id: string;
    state: OrderState;
  }>) {
    await store.save({
      id: order.id,
      state: order.state,
    });
  }
}

export function shipOrder(order: {
  id: string;
  state: OrderState;
}) {
  return {
    ...order,
    state: "shipped",
  };
}

Larger System-Level Good TypeScript Example

type LegacyOrder = {
  id: string;
  status: string;
};
type OrderState = "draft" | "paid" | "shipped" | "cancelled";
type Order = {
  id: string;
  state: OrderState;
};

type OrderStore = {
  findLegacyOrdersWithoutState(): Promise<LegacyOrder[]>;
  saveTypedState(order: Order): Promise<void>;
};

type Metrics = {
  increment(name: string): void;
};

function parseLegacyOrder(order: LegacyOrder): Order | null {
  if (order.status === "draft" || order.status === "paid" || order.status === "shipped") {
    return { id: order.id, state: order.status };
  }
  if (order.status === "void") return { id: order.id, state: "cancelled" };
  return null;
}

export async function backfillTypedOrderState(store: OrderStore, metrics: Metrics) {
  const legacyOrders = await store.findLegacyOrdersWithoutState();

  for (const legacy of legacyOrders) {
    const parsed = parseLegacyOrder(legacy);
    if (!parsed) {
      metrics.increment("orders.unknown_legacy_status");
      continue;
    }
    await store.saveTypedState(parsed);
  }
}
type LegacyOrder = {
  id: string;
  status: string;
};
type OrderState =
  | "draft"
  | "paid"
  | "shipped"
  | "cancelled";
type Order = {
  id: string;
  state: OrderState;
};

type OrderStore = {
  findLegacyOrdersWithoutState(): Promise<
    LegacyOrder[]
  >;
  saveTypedState(
    order: Order,
  ): Promise<void>;
};

type Metrics = {
  increment(name: string): void;
};

function parseLegacyOrder(
  order: LegacyOrder,
): Order | null {
  if (
    order.status === "draft" ||
    order.status === "paid" ||
    order.status === "shipped"
  ) {
    return {
      id: order.id,
      state: order.status,
    };
  }

  if (order.status === "void")
    return {
      id: order.id,
      state: "cancelled",
    };

  return null;
}

export async function backfillTypedOrderState(
  store: OrderStore,
  metrics: Metrics,
) {
  const legacyOrders =
    await store.findLegacyOrdersWithoutState();

  for (const legacy of legacyOrders) {
    const parsed = parseLegacyOrder(legacy);

    if (!parsed) {
      metrics.increment(
        "orders.unknown_legacy_status",
      );

      continue;
    }

    await store.saveTypedState(parsed);
  }
}

What Changed

  • The bad version casts the whole legacy table into the new type and hopes every status matches.
  • The good version adds a parser, handles known legacy names, and measures unknown data.
  • The migration can run safely before the old status column or old callers are removed.

When To Use It

Pragmatic migrations: When To Use It

Use This When

  • The safer type is correct, but many old callers still pass loose data.
  • The risky behavior is concentrated at a boundary, route, store, or job that can be improved first.
  • You need to keep shipping while reducing casts, optional fields, or broad strings.

Avoid This When

  • The current code is small enough to fix cleanly in one change.
  • The migration adapter would become a permanent second API with no removal plan.
  • The migration hides invalid data instead of measuring or rejecting it.

Tradeoffs

Pragmatic migrations add temporary compatibility code. That is acceptable when the code has an owner, tests, and a removal condition. It becomes debt when the adapter spreads everywhere or accepts every shape forever.

  • Strict TypeScript settings
  • Parsed inputs
  • Illegal states unrepresentable

Practice Prompt

Pragmatic migrations: Practice Prompt

Beginner Exercise

Find a value that should become a narrower type but is still passed around as string, number, or any. Identify the first boundary that could safely parse it.

Intermediate Exercise

Add a small parser or adapter for that boundary and update one core function so it accepts the narrower type.

Stretch Exercise

Write a three-step migration note: first safe entry point, next callers to move, and the condition for deleting the compatibility path.

Reflection Question

Is this migration reducing unsafe shapes over time, or is it adding a new permanent layer that future code will have to understand?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.