Last month I reviewed a startup's codebase. 40k lines of JavaScript, three years of accumulated debt, and the product owner told me: "Every time we ship a feature, four or five other things break, and nobody knows why." Familiar story. The reason wasn't mysterious. When you write user.profile.name, nobody remembers that profile can be null, because there is no mechanism that reminds them. Just JSDoc comments scattered across some files.
That is what TypeScript is for. It is not a magic performance tool. It changes nothing at runtime. The only thing it does is whisper, while you are writing code, that this variable might be undefined and you should handle it. Sounds simple. In a codebase that 50+ engineers touch, that whisper catches half the bugs in your pull requests before they ever ship.
Why TypeScript exists and what it actually fixes
JavaScript's most loved and most hated property is the same: its flexibility. You write const user = {}, then user.name = "Ahmet", then user.name = 42, and nobody complains. Wonderful when you are alone. Hellish two years into an eight-person team.
TypeScript adds a structural type system to JavaScript. When you say "this function takes a User," it doesn't just remind you what User looks like. It verifies that the object you actually pass matches that shape. The check happens at compile time. By the time your code ships to production, TypeScript is gone. Plain JavaScript runs in the browser.
What does it fix in practice? Three things: refactor safety, free documentation, and smarter editor suggestions. Sounds like marketing copy, so let me make it concrete. You have a 200-file project and you want to rename a field from user_name to username. In JavaScript, that is find-replace and a prayer. In TypeScript, the compiler shows you the 23 places you missed.
tsconfig.json and the habit of disabling strict mode
The most common mistake I see in TypeScript projects is leaving "strict": false in tsconfig.json, or never enabling it. That is like buying a gym membership and never going. The types exist but you require nothing. Everyone scatters any across the codebase, and you complain that TypeScript never catches anything.
For a new project, strict mode shouldn't even be a discussion:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true
}
}
strict: true is actually a bundle of flags: strictNullChecks, noImplicitAny, strictFunctionTypes, and friends. The most important one is strictNullChecks, because it pushes the so-called billion-dollar mistake (null reference) out of runtime and into the compiler.
I also recommend noUncheckedIndexedAccess separately. Default behavior: const arr: string[] = []; const first = arr[0]; the compiler treats first as string. The array is empty, so first is actually undefined. With this flag on, first becomes string | undefined. Annoying for the first week. Lifesaving forever after.
Types you'll reach for every day
If you open the TypeScript handbook and read the full utility type list, your eyes will glaze over. In real life you use five or six of them daily and the rest are situational. My favorites:
Partial makes every field optional. Perfect for update endpoints. Pick selects only certain fields. Omit drops fields and returns the rest as a new type, which is great for cleaning API responses. Record models key-value maps.
interface User { id: string; email: string; password: string; createdAt: Date; }// Don't return password to the client type PublicUser = Omit;
// Update endpoint: every field optional, except id and createdAt type UserUpdate = Partial>;
function updateUser(id: string, patch: UserUpdate) { // patch.email optional, patch.password optional }
ReturnType and Awaited are two more I keep in my pocket. Chains like Awaited> show up everywhere in API layers.
type vs interface: the never-ending debate
People practically pick teams over this on Twitter. The practical answer is much more boring: both work, and 95% of the time it doesn't matter. Still, my rule of thumb:
Use interface for object shapes. interfaces with the same name automatically merge (declaration merging), which saves your life when you extend third-party libraries. Error messages also keep the interface name intact, while type aliases sometimes expand into unreadable blobs.
Use type when you need unions, intersections, mapped types, or conditional types. interface simply can't do those.
// interface: an object shape interface Project { id: string; name: string; ownerId: string; }
// type: required for unions type TaskStatus = "todo" | "in_progress" | "done"; type TaskWithMeta = Task & { meta: Record };
The only consistent rule: pick a convention as a team and stick to it. I have been on Team Interface for years, but it is not a religious choice. The error messages are simply cleaner.
Generics: scary at first, simple in practice
The first time I saw function fetch(url: string): Promise I thought, what is this nonsense. A week later I couldn't live without it. The deal with generics is this: you write a function, but the caller decides the type, not you. It is a way of saying "I'll tell you what type later."
// Without generics: a function per endpoint function fetchUser(id: string): Promise { / ... / } function fetchProject(id: string): Promise { / ... / }// With generics: one function, caller picks the type async function fetchById(endpoint: string, id: string): Promise { const res = await fetch(
/${endpoint}/${id}); return res.json() as T; }
const user = await fetchById("users", "abc"); const project = await fetchById("projects", "xyz");
Don't forget generic constraints. guarantees that whatever T is, it has at least an id. These constraints turn generics from "could be anything" into "has at least this shape."
My advice for beginners: don't try to write your own generics in the first three months, just learn how to call them. Once you understand how to invoke library generics correctly, writing your own feels natural.
Discriminated unions: TypeScript's sharpest weapon
I always use the same example here because the win is so clean. You're modeling an API response. Three states: loading, success, error. The naive approach:
interface State {
loading: boolean;
data?: User;
error?: string;
}
Problem: can loading be true while data is set? Can error and data coexist? The type allows invalid states, and you have to write defensive if statements everywhere just in case.
Now model the same thing as a discriminated union:
type State = | { status: "loading" } | { status: "success"; data: User } | { status: "error"; message: string };
function render(state: State) { switch (state.status) { case "loading": return "Loading..."; case "success": return state.data.email; // TS knows data exists here case "error": return state.message; // message exists here } }
The compiler narrows the type based on state.status and only exposes valid fields. Invalid states become impossible to express. If you're building any UI that orchestrates multiple async sources, this pattern alone is worth learning TypeScript for.
In Poitim's task module this pattern is everywhere. A task can be in one of eight states, each requiring different fields. Maintaining that without discriminated unions would be brittle.
any, unknown, never: the dangerous trio
Misuse these three and your entire safety net evaporates. Let's go through them.
any is the off switch for type checking. When you reach for any, you are saying "I'll do whatever I want with this value, don't check it." The trouble is that any is contagious: anything derived from an any value becomes any too. A single any can spread across 30 files. Avoid it. When you absolutely must use it, leave a comment explaining why.
unknown is the smart cousin of any. It says "I don't know what this is, and I know that I don't know." You cannot access an unknown value directly; you have to narrow it first. Anything coming from JSON.parse, an external API, or localStorage should be unknown.
function parsePayload(raw: string): User {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === "object" &&
parsed !== null &&
"email" in parsed &&
typeof parsed.email === "string"
) {
return parsed as User;
}
throw new Error("Invalid payload");
}
Cleaner option: a runtime validator like zod or valibot. Don't skip validation when you cross the boundary from unknown to User.
never means "this code should never run." It is gold for exhaustiveness checks in switch statements:
function handle(status: TaskStatus) {
switch (status) {
case "todo": return "...";
case "in_progress": return "...";
case "done": return "...";
default:
const _exhaustive: never = status;
throw new Error(Unhandled: ${_exhaustive});
}
}
Add a new value to TaskStatus and the compiler will break the default case. The most reliable way to catch missed cases during refactors.
Migrating an existing JS project: a phased plan
Anyone who says "let's rename all .js to .ts on Monday morning" is either very brave or hasn't seen the codebase. A real migration takes weeks or months. Here's the order I follow:
1. Setup. Add typescript, @types/node, and the type packages for your framework. Create a tsconfig.json but start with strict: false, allowJs: true, and checkJs: false. Goal: the project still builds.
2. JSDoc phase (optional). Add JSDoc types to critical functions. @param and @returns are read by TypeScript. The compiler can start surfacing errors before any file extensions change.
3. Start with leaf files. Helpers, constants, small utilities. Convert these to .ts first. Few things depend on them, so blast radius is small.
4. Domain models. Build core types like User, Project, Task in a types/ folder. Inject them gradually into existing functions.
5. Save the messy files for last. The 2000-line utils.js goes last because everything depends on it.
6. Strict mode day. Once everything is .ts, flip strict: true and start fixing. You can also enable flags incrementally, starting with strictNullChecks. In my experience you get 2k-3k errors and clean them up over a week.
While you orchestrate this across team members and ongoing work, one thing to remember: TypeScript migration shouldn't require a feature freeze. It runs in parallel with feature development. Otherwise it never finishes, because the codebase keeps growing daily.
Pitfalls beginners fall into
Teaching TypeScript to junior engineers, I see the same mistakes repeat. Listing them so you don't make them.
Using any as a hideout. Hitting a type error, thinking for five minutes, then writing : any to escape. You were five minutes away from learning something. any saves time today and steals it tomorrow.
Annotating types everywhere. const name: string = "Ahmet" is redundant; TypeScript already infers name as string. Annotate function parameters, public return types, and uninitialized variables. Let inference handle the rest.
Treating type assertions (as Foo) as validation. as only lies to the compiler, it validates nothing at runtime. response.data as User doesn't make data actually be a User. Use a validation library or write a real type guard.
Reaching for enum by default. TypeScript's enum is controversial: it produces runtime objects and is hard to tree-shake. Modern stacks prefer const assertions and union types:
// Instead of enum const TaskStatus = { Todo: "todo", InProgress: "in_progress", Done: "done", } as const;
type TaskStatus = typeof TaskStatus[keyof typeof TaskStatus];
Ignoring editor errors. Merging code with red squiggles because "it works" is tomorrow's tech debt. Every TypeScript message has a reason. If it doesn't make sense, give it 30 seconds of your attention.
Copy-pasting instead of using generics. Writing three functions fetchUser, fetchProject, fetchTask when one fetchById would do. DRY applies at the type level too.
TypeScript is a tool. Like all good tools, it slows you down at first and speeds you up later. Your first project might lose two weeks of productivity. By the third project, writing without it feels unimaginable. If you are scaling a project seriously, you need a strong reason not to invest here.
One last thing. TypeScript doesn't promise paradise. Types can be wrong, code can still blow up at runtime, and bad architecture isn't fixed by adding annotations. But used well, it extends the lifespan of the code you write by an order of magnitude. For any serious frontend team, this isn't really up for debate anymore.