TypeScript Anti-Patterns That Cost You Twice: Build Time, Runtime, and the Interview
The TypeScript patterns I keep deleting in code review, what to write instead, where the cost lands, and the interview question each one turns into.
3 Aug 2026

TypeScript disappears when you build. Nothing you write in the type system exists when the code runs. So every pattern costs you in one of three places: the compiler, the JavaScript that gets shipped, or the shape of your data at runtime. Knowing which one is most of the skill.
Here is the list.
Use unknown, not any
// Don't
function applyDiscount(order: any, rule: any) {
return { ...order, total: order.total - rule.amount };
}
const result = applyDiscount(order, rule);
result.totl; // no error
result.whatever(); // no error either
The problem is not the function. It is that result is now any, so is everything you take out of it, and so is everything you build from that. One any in a shared helper turns off checking in files you have never opened. You do not get a warning. You get silence, which looks exactly like working code.
// Do
function applyDiscount<T extends { total: number }>(order: T, rule: DiscountRule): T {
return { ...order, total: order.total - rule.amount };
}
const result = applyDiscount(order, rule);
result.totl; // Error: did you mean 'total'?
When you truly do not know the type, use unknown. It accepts any value and lets you do nothing with it until you check.
function handle(input: unknown) {
input.trim(); // Error: 'input' is of type 'unknown'
if (typeof input === "string") {
input.trim(); // fine, TypeScript now knows it is a string
}
}
Interview: the difference is not the definition, it is how far it spreads. any leaks out to your callers. unknown stays where you put it.
Parse at the boundary, do not cast
// Don't
const order = (await res.json()) as Order;
console.log(order.customer.email); // crashes if the API sent something else
as produces no code at all. Nothing is checked. You have told the compiler to stop asking questions about the one value you know least about, and the crash lands somewhere else entirely.
// Do
import { z } from "zod";
const OrderSchema = z.object({
id: z.string(),
total: z.number(),
customer: z.object({ email: z.string() }),
});
type Order = z.infer<typeof OrderSchema>;
const order = OrderSchema.parse(await res.json());
Now a bad response fails on that line, with a message that says which field was wrong. You also get the type for free from the schema, so there is one definition instead of two that drift apart.
Two places as is still right:
const canvas = document.getElementById("chart") as HTMLCanvasElement; // you wrote the HTML
const config = { mode: "dark" } as const; // different feature, keeps literal types
Discriminated unions, not optional fields
// Don't
interface RequestState<T> {
loading: boolean;
error?: Error;
data?: T;
}
// Is this legal? Nothing stops it, and every component decides for itself.
const state = { loading: true, error: new Error("boom"), data: order };
Three optional fields means eight possible combinations and about four that make sense. Every reader has to guess the rules for the rest, and the guesses do not match.
// Do
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "failure"; error: Error };
Now the bad combinations cannot be written down. Check status and you get exactly the fields that belong to it, with no optional chaining anywhere:
function render(state: RequestState<Order>) {
switch (state.status) {
case "idle":
return placeholder();
case "loading":
return spinner();
case "success":
return orderView(state.data); // data is definitely here
case "failure":
return errorView(state.error); // error is definitely here
default:
return assertNever(state); // add a fifth state, this line errors
}
}
function assertNever(value: never): never {
throw new Error(`unhandled: ${JSON.stringify(value)}`);
}
That assertNever is the part people skip, and it is the part that pays off. The day someone adds { status: "cancelled" }, the compiler points at every switch that has not been updated.
There is a speed win too. JavaScript engines remember the shape of an object, meaning which keys it has and in what order, and reading a property is fast when the code always sees the same shape. Each variant above is built in one place with a fixed set of keys, so it stays fast. The optional version builds a different shape in each branch, and delete state.error makes the object slow for the rest of its life.
Interview: "model the state of a data fetch" is a common one. Reach for the union without being asked, and say the word exhaustiveness.
Const objects, not enums
// Don't
export enum Status {
Active,
Suspended,
Closed,
}
Enums are the one TypeScript feature that does not disappear. That line becomes real JavaScript:
var Status;
(function (Status) {
Status[(Status["Active"] = 0)] = "Active";
Status[(Status["Suspended"] = 1)] = "Suspended";
Status[(Status["Closed"] = 2)] = "Closed";
})(Status || (Status = {}));
Object.keys(Status); // ["0", "1", "2", "Active", "Suspended", "Closed"]
Two problems. Bundlers cannot usually prove that block is safe to delete, so it ships even when nothing imports it. And numeric enums store the mapping in both directions, so anything that loops over the keys gets six entries instead of three.
// Do
export const Status = {
Active: "active",
Suspended: "suspended",
Closed: "closed",
} as const;
export type Status = (typeof Status)[keyof typeof Status];
// Status is "active" | "suspended" | "closed"
function setStatus(next: Status) {}
setStatus(Status.Active); // same as before
setStatus("active"); // also fine
setStatus("archived"); // Error
A plain object the bundler understands, string values you can read in a log or send as JSON, and the same autocomplete you had before.
Skip const enum as the fix. It breaks under esbuild, SWC and Babel, and across packages it copies the values into other people's builds, so changing one is a breaking change nothing warns you about.
Interfaces, not big intersections
// Don't
type ButtonProps = BaseProps & ThemeProps & A11yProps & LayoutProps;
// Do
interface ButtonProps extends BaseProps, ThemeProps, A11yProps, LayoutProps {}
TypeScript works out an interface once and remembers the answer. An intersection is worked out again everywhere it is used, so a component library built out of ampersands is a common reason the editor takes seconds to autocomplete.
The errors are better too. extends complains on the line with the mistake, an intersection waits and confuses you later:
interface A {
id: string;
}
interface B {
id: number;
}
type C = A & B; // no complaint here. C["id"] is silently never
const c: C = { id: "1" }; // error here instead, and it reads badly
interface D extends A, B {} // Error on this line: 'id' is not compatible
Put a type on everything you export
// Don't
export function createOrderService(deps: Deps) {
return { place, cancel, quote };
}
Without a return type, TypeScript has to read the whole function body to work out what other files can see. Every package that imports you waits for that. Worse, your public API is now whatever the body happens to return, so an internal tidy-up changes it and the breakage turns up in someone else's build.
// Do
export interface OrderService {
place(input: PlaceInput): Promise<Order>;
cancel(id: string): Promise<void>;
quote(input: QuoteInput): Promise<Quote>;
}
export function createOrderService(deps: Deps): OrderService {
return { place, cancel, quote };
}
Inference is great inside a function. At the edge of a module, write the type down. If you want this enforced, isolatedDeclarations makes it a rule, and in return your .d.ts files can be built in parallel, which is the biggest build-time win available in a monorepo.
Use import type, and delete the barrel files
// Don't
import { OrderService, createOrderService } from "./order-service";
If OrderService is only used as a type, that line still looks like a real dependency to anything reading the file before types are known, which includes your bundler. It keeps modules alive that should have vanished, and it is a common cause of import cycles that only show up in the production build.
// Do
import type { OrderService } from "./order-service";
import { createOrderService } from "./order-service";
Turn on verbatimModuleSyntax and there is no guessing left: anything marked type is removed, everything else is kept exactly as written.
Then there are barrel files:
// Don't: src/index.ts
export * from "./order-service";
export * from "./billing";
export * from "./reporting";
// ...forty more
// somewhere else
import { formatMoney } from "../index"; // drags in all forty
// Do
import { formatMoney } from "../money/format";
One import through a barrel pulls every module behind it through the compiler, on every check. Barrels are fine as the front door of a published package. Inside an app, import the file you actually want.
Check your compile target
// Don't
{ "target": "ES5", "downlevelIteration": true }
Under ES5 every async function is rewritten into a state machine wrapped in helpers:
var getOrder = function (id) {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
/* ...your three lines of code, buried... */
});
});
};
Object spread becomes __assign, array spread becomes __spreadArray, and for...of over an array stops being a simple index loop and allocates an object on every step. All of those are fast native features now, and you are shipping extra bytes to avoid using them.
// Do
{ "target": "ES2022", "importHelpers": true }
Let your bundler handle old browsers if you still support any. importHelpers with tslib means the helpers you do need are imported once instead of copied into every file.
Check what your build actually produces before arguing about this. Most projects have not looked at that setting since the day they were created.
I write about system design and the senior-to-staff transition every week in Monday BY Gazar on Substack, and I break down architecture and engineering decisions on Gazar Breakpoint on YouTube.
If you are an engineer targeting staff or principal and want this kind of thinking applied to your actual situation, I do 1:1 mentorship.
From Senior to Staff: Master the Architecture Skills That Get You Promoted
Go from shaky in design reviews to the engineer everyone trusts to architect the hard stuff.
View the live cohortKeep reading
- The Frontend Toolchain Is Now Written in Rust and Go. What That Means for You
- oxlint vs ESLint: Why I Replaced ESLint with oxlint
- SystemJS Is Dead. Native ESM Finally Won.
- Replace Axios with a Simple Custom Fetch Wrapper (Production-Ready Guide)
- Stop Shipping ChatGPT Wrappers. Ship an Agent in TypeScript, or Don't Bother.
- What Developers Are Saying About React 19: Pros and Cons