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
statuscolumn 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.
Related Concepts
- 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.