I didn't switch to TypeScript because I read a convincing blog post. I switched because I was maintaining a JavaScript/Express/MongoDB codebase that was slowly becoming hostile to change. Every refactor was a gamble. Every time I touched a function I hadn't written, I was reverse-engineering its contract from call sites and console.logs. The codebase worked — until it didn't, and the failures were always the quiet kind.

The recurring villain was undefined. Values that should have been there, weren't. Fields that looked present in one code path turned out to be missing in another. In JavaScript, nothing stops undefined from flowing through your entire call chain until it causes damage somewhere far from the source. You don't get a compile error. You don't even get a runtime error half the time — you just get wrong results, silently.

Eventually I made the call: the next project starts from scratch in TypeScript. Not a gradual migration — a clean break. The old codebase had taught me what I didn't want, and trying to retrofit types onto it would have been fighting the existing architecture at every step. That decision was the single best technical call I've made.

The undefined problem

If I had to name the single category of bugs that pushed me toward TypeScript, it would be undefined values passing silently through code that assumed they existed. In TypeScript, you explicitly mark fields as optional with ?, and the compiler forces you to handle the case where they're missing. In JavaScript, you just hope for the best.

Here's the kind of thing that bit me repeatedly: a service fetches a record, passes it to a function that formats a response, which calls another function that accesses a nested field. Somewhere in the chain, a field can be undefined — maybe the record was created before that field existed, maybe an upstream API doesn't always include it. In JavaScript, nothing flags this. The value flows through, and you get a cryptic error in production or — worse — silently wrong data.

JavaScript
// This looks perfectly fine. It is not.
async function notifyUser(user) {
  const payload = buildEmailPayload(user.email, user.name);
  await emailService.send(payload);
  // What if user.email is null?
  // What if user.name is undefined?
  // You'll find out in production. Maybe.
}

// Called from 4 different places, each with different
// guarantees about which fields are actually present.

With TypeScript, this entire class of problems is structurally impossible:

TypeScript
interface User {
  id: string;
  name: string;
  email: string | null;  // Explicit: this CAN be missing
}

async function notifyUser(user: User): Promise<void> {
  if (!user.email) {
    // Compiler FORCES you to handle this case.
    // You can't just pretend email is always there.
    logger.warn(`User ${user.id} has no email, skipping`);
    return;
  }

  // After the null check, TS narrows the type.
  // user.email is guaranteed string here — not string | null.
  const payload = buildEmailPayload(user.email, user.name);
  await emailService.send(payload);
}

The difference is not just catching a bug — it's that the type system makes optional fields a first-class concept. In TypeScript, if a field can be undefined, you either mark it with ? or type it explicitly as | null. The compiler then forces every consumer to handle the missing case. No more undefined flowing silently through your system until it surfaces as a broken query or a corrupted record three services downstream.

Why I started fresh instead of migrating

People sometimes ask why I didn't just gradually add TypeScript to the existing JavaScript project. I tried. Or rather, I considered it seriously and realized the economics didn't work. The existing codebase had accumulated years of implicit contracts — functions that assumed certain shapes, middleware that silently mutated objects, database queries that relied on JavaScript's loose type coercion. Adding TypeScript on top of that wouldn't have fixed the problem; it would have given me a typed facade over an untyped foundation.

Starting fresh with TypeScript and NestJS meant I could define types from the ground up. Every database entity, every DTO, every service interface — typed from day one. The immediate difference was night and day. Refactoring went from being the scariest part of the job to the easiest. You change an interface, the compiler shows you every place that needs updating, and when the red lines are gone, you're done. No guessing, no "did I miss something" anxiety.

The tooling difference alone justified the decision. VS Code with TypeScript is a fundamentally different experience than VS Code with JavaScript. Autocompletion actually works. Jump-to-definition takes you where you need to go. Rename-symbol catches everything. These aren't small conveniences — they compound into a measurably faster development cycle, every single day.

The real metric: On the old JavaScript codebase, every structural change came with dread — you were never sure you caught everything. On the TypeScript project, refactoring generates a finite, explicit checklist from the compiler. When the list is empty, you're genuinely done. That difference in confidence is worth the entire cost of the migration.

The underrated power of utility types

One thing I've noticed — both in my own code and when reviewing others' — is that most TypeScript developers barely scratch the surface of what the type system offers. People define their interfaces, add some string and number annotations, and call it a day. They're getting maybe 40% of TypeScript's value.

The real power unlocks when you start using utility types — and I mean truly using them, not just knowing they exist. Partial<T>, Pick<T, K>, Omit<T, K>, Required<T>, Record<K, V> — these let you derive types from other types instead of duplicating definitions.

TypeScript — Utility Types in Practice
interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'member' | 'viewer';
  createdAt: Date;
  lastLoginAt: Date | null;
}

// Instead of defining a separate CreateUserDto manually:
type CreateUserDto = Omit<User, 'id' | 'createdAt' | 'lastLoginAt'>;

// Update operations — everything optional except id:
type UpdateUserDto = Partial<Omit<User, 'id' | 'createdAt'>>;

// Only the fields the frontend needs for display:
type UserSummary = Pick<User, 'id' | 'name' | 'role'>;

// Permission map derived from the role type:
type RolePermissions = Record<User['role'], string[]>;

// One source of truth. Change User, every derived
// type updates automatically. No drift. No duplication.

When you derive types instead of duplicating them, you have one source of truth. Change the User interface, and every DTO, every response type, every query shape updates with it. In codebases where I see separate, manually maintained interfaces for User, CreateUserInput, UpdateUserInput, UserResponse, and UserListItem — all defined independently — I know there are bugs hiding in the gaps between them.

A pattern I see constantly: Codebases with five separately maintained interfaces — User, CreateUserInput, UpdateUserInput, UserResponse, UserListItem — all defined independently, all drifting apart. Three lines of utility types would replace all of them with a single source of truth. This is one of the first things I fix when I join a project.

Five things I've learned the hard way

1. Strict mode — with one exception

Enable "strict": true in your tsconfig.json from day one. I've seen teams start with loose settings to "ease the transition" and then never tighten them because it would mean rewriting half the codebase. You end up with TypeScript that provides a false sense of security — the syntax is there, but the safety isn't.

My one exception: during early prototyping, before the main implementation plan is set, I allow myself to be looser. When I'm exploring an approach, testing whether an architecture works, I'll use quicker type annotations and accept some shortcuts. But the moment the prototype validates the direction and real implementation begins, strict mode is non-negotiable. The prototype either gets rewritten properly or gets thrown away — loose types never make it into the production codebase.

2. Type your API boundaries first

When starting a new service, the first thing I define is the request and response interfaces for every endpoint. Before writing a single line of business logic. These types become the contract between frontend and backend, and they inform every decision downstream. If you share these types across the stack, you eliminate an entire category of integration bugs.

3. Discriminated unions over boolean flags

Instead of status: string with an isActive boolean and a cancelledAt that may or may not be null, use discriminated unions. They force every consumer to handle every state explicitly:

TypeScript
// Instead of: { status: string; isActive: boolean; cancelledAt?: Date }

type Subscription =
  | { status: 'active';    startedAt: Date; renewsAt: Date }
  | { status: 'cancelled'; startedAt: Date; cancelledAt: Date }
  | { status: 'expired';   startedAt: Date; expiredAt: Date };

// If status is 'active', renewsAt exists.
// If status is 'cancelled', cancelledAt exists. No guessing.

4. Treat any as a code smell — mostly

Every any in your production code is a hole in the safety net. I'm strict about this: in the main codebase, any is a red flag in code review. Use unknown when you genuinely don't know the type, then narrow it with type guards. The same prototyping exception applies — when I'm spiking an approach before committing to it, any is acceptable as a placeholder. But it never survives into the implementation phase. If I see any in a PR that isn't a prototype, it gets sent back.

5. Types are documentation that can't lie

I stopped writing most JSDoc comments after moving to TypeScript. Not because documentation doesn't matter, but because the types are the documentation. Unlike comments or README files, types can't drift out of sync with the actual code — the compiler won't let them.

TypeScript and AI: an unexpected multiplier

One of the less-discussed advantages of TypeScript in 2026 is how much better it plays with AI coding tools. When your codebase has explicit interfaces, AI assistants generate significantly more accurate code — they can read your type definitions and produce completions that actually compile on the first try. In a JavaScript codebase, AI is guessing about data shapes. In TypeScript, it has a contract to work against.

I wrote more about this in my post on AI-augmented development workflows, but the short version is: well-typed code isn't just better for humans — it's better for every tool in your pipeline, including the AI ones.


The bottom line

TypeScript didn't click for me because someone explained the theory. It clicked because I hit the wall on a JavaScript codebase where undefined kept sneaking through, refactoring kept getting harder, and the tooling couldn't help me because it had nothing to work with. Starting a new project from scratch in TypeScript — instead of trying to gradually migrate — was what finally showed me the difference.

TypeScript is not about type annotations. It's about making your codebase trustworthy. It's about changing code six months from now without second-guessing whether you've broken something three layers deep. It's about the compiler catching the undefined before your users do. And yes — it's about being pragmatic. Strict where it matters, flexible during exploration, and always moving toward more safety, not less.

If you're starting a new Node.js backend today, TypeScript isn't a nice-to-have. It's the foundation everything else is built on.