JavaScript vs TypeScript: When to Add Types and When Not To

JavaScript vs TypeScript: When to Add Types and When Not To

A few years ago I shipped a bug that took a production API down for ninety seconds. The line of code looked harmless: const total = order.items.reduce((sum, item) => sum + item.price, 0). The problem was that one upstream service had started sending price as a string — "19.99" instead of 19.99. JavaScript happily concatenated. The first call returned "019.99", the second "019.9919.99", and by the time alerts fired we had a string megabyte long flowing through our checkout flow.

TypeScript would have flagged that on the first character. sum + item.price where item.price: string is a type error. Not a runtime crash, not a Sentry alert — a red squiggle in the editor before the code ever ran.

That story is the entire case for TypeScript in one paragraph. It is also the entire case against it: most of your code does not look like that, the type was already obvious to a careful reviewer, and you paid a tax in build steps and .d.ts files for everywhere else. Whether the trade is worth it depends on the project, the team, and honestly the day.

What TypeScript Actually Is

TypeScript is JavaScript plus a type system that runs at compile time and disappears at runtime. The compiler (tsc) reads your .ts files, checks the types, and emits plain .js. The browser or Node runtime never sees a type — by the time the code runs it is identical to handwritten JavaScript.

That detail matters. TypeScript is not a different language. It is a static analyzer with very good ergonomics, bolted onto the JavaScript you already know. Every valid JavaScript program is, with one rename, a valid TypeScript program. You can adopt it gradually, file by file, and the runtime stays the same. See the TypeScript handbook for the official tour.

// JavaScript
function greet(user) {
  return `Hello, ${user.name}`;
}

greet({ nme: "Ada" });  // typo. Returns "Hello, undefined". Runs fine.
// TypeScript
function greet(user: { name: string }) {
  return `Hello, ${user.name}`;
}

greet({ nme: "Ada" });  // Error: Property 'name' is missing

Same logic, same output for valid input — but the second version refuses to compile when the input is wrong.

The Real Bugs Types Catch

The hype around TypeScript talks about scale and refactoring. The day-to-day reality is grubbier and more useful. Here are the bugs I see types catch most often:

  • Renamed fields. A field gets renamed in one file and three call sites are missed. JavaScript fails silently with undefined. TypeScript fails loudly at compile time.
  • Optional vs required confusion. Functions that sometimes return null and sometimes return an object. JavaScript blows up on result.id. TypeScript forces if (result) first.
  • Wrong number of arguments. setTimeout(fn, 100, extraArg) works in JS. TypeScript checks the function signature.
  • Enum drift. A status string gets "pending" | "active" | "closed", then someone writes if (status === "closed_v2") after a refactor. TypeScript flags the comparison as impossible.
  • Object shape mismatches at API boundaries. This is the category my opening bug fell into. When you parse JSON from a network call, you have no static guarantee what came back — but you can pair TypeScript with Zod or similar runtime validators to bridge the gap.

You can convert a JSON sample to a TypeScript interface in a few seconds with JSON to TypeScript, or to a Zod schema for runtime checking with JSON to Zod Schema. For one-off API responses this is faster than hand-typing.

What TypeScript Actually Costs

People who recommend TypeScript usually skip the cost section. There is one. It is not huge, but it is real, and pretending otherwise just makes the trade-off harder to evaluate.

  • A build step. Plain JavaScript runs in the browser or Node directly. TypeScript needs tsc, or esbuild, or tsx, or some bundler with a TS plugin. That means a tsconfig.json, a dist/ folder, a watch mode, source maps, and one more thing to break.
  • Type definitions for everything. Most npm libraries ship .d.ts files now (or have community ones in @types/...), but every so often you'll find one that doesn't. You'll write declare module 'weird-package' more than once.
  • Cognitive overhead for simple code. A 30-line script that reads a file and prints a number does not benefit from generics and conditional types. The types just slow you down.
  • The compiler's worldview. TypeScript has opinions. strictNullChecks is one of them. Once you enable it (and you should), you'll spend the first week adding ? and ! and if (x) guards in places where JavaScript was perfectly happy with undefined.

The total tax is maybe ten percent more code and a slightly slower edit-compile loop. For projects above a certain size, you make that back many times over in bugs not shipped. Below that size, you probably don't.

When JavaScript Is Still the Right Answer

Plain JavaScript is correct when the project meets most of these criteria: small (under ~1,000 lines), short-lived, written by one person, and the scope is well understood up front. Examples that fit:

  • A throwaway script that scrapes a URL once.
  • A 50-line config tool you'll run by hand twice.
  • An HTML file with a single <script> doing form validation.
  • A prototype where the goal is to learn whether the idea works at all.
  • Code in a Jupyter-style notebook where types just clutter the cells.

For these, TypeScript is overhead without payoff. You can also use JSDoc type annotations inside .js files to get most of the editor benefits (autocomplete, hover docs, inline type checks via // @ts-check) without committing to a build step. That's an underrated middle ground.

/**
 * @param {{ name: string, age: number }} user
 * @returns {string}
 */
function greet(user) {
  return `Hello, ${user.name}, age ${user.age}`;
}

VS Code will type-check this exactly like a .ts file, and it still runs as plain JavaScript. No build step, partial safety.

When TypeScript Pays for Itself

The flip side. TypeScript earns its keep when:

  • The codebase is large or growing. Anything past about 5,000 lines benefits clearly. Past 50,000 lines, going without types is professional negligence.
  • More than one person works on it. Types are documentation that can't drift. They tell the next developer what shape a function expects without forcing them to read the implementation.
  • It will live more than six months. Refactoring with types is a different experience. You change a field name, the compiler points to every call site, you fix them, you ship. No grep, no surprises.
  • It crosses a network boundary. APIs, message queues, file formats — anywhere you serialize and deserialize data, types catch the slow-rolling shape mismatches that bring services down at 3 AM.
  • It uses a framework with strong type definitions. React, Vue, Svelte, NestJS, Drizzle, tRPC — all of these are dramatically nicer with TypeScript because the framework's own types do work for you.

The State of JS survey shows TypeScript adoption has crossed the threshold where it's the default for serious work. That doesn't mean it's required — it means the burden of proof has shifted. If you choose plain JavaScript on a real project today, expect to explain why.

Migrating from JavaScript to TypeScript

If you're sitting on a JavaScript codebase and want to add types, the path is well-trodden. Don't do it all at once.

  1. Add a tsconfig.json with allowJs: true and checkJs: false. This lets .js and .ts coexist.
  2. Rename your most-imported file to .ts. Start with utilities, types, or constants — leaves of the dependency tree, not roots.
  3. Fix what breaks. The compiler will complain. Most fixes are small: add a parameter type, mark something optional, narrow a string | undefined.
  4. Repeat outward. Convert files in order of import depth. Every conversion makes the next one cleaner.
  5. Turn on strict: true last. This enables strictNullChecks, noImplicitAny, and friends. Doing this on day one of a migration is overwhelming. Doing it last is just one more pass.

Microsoft has written extensively on real migration patterns from teams larger than yours. For one-off code translation needs while you migrate, JavaScript Beautifier and JavaScript Minifier handle formatting either way — TypeScript output looks the same as hand-written JS once compiled.

Where TypeScript Won't Save You

Honest disclosure: types catch a category of bug, not all bugs. They are silent on:

  • Logic errors. total = price - discount when you meant +. Types are perfect, behavior is wrong.
  • Off-by-one mistakes. A number is a number whether it's right or off by one.
  • Race conditions and concurrency bugs. TypeScript has no model of time.
  • External data shape drift. The compiler trusts what you tell it the API returns. If the API changes and you don't update the type, you're back to JavaScript-grade safety until something crashes.
  • Anything any touches. Once a value has type any, all type checking around it is off. any is contagious. Avoid it; prefer unknown and narrow.

Types are a category of test. They don't replace unit tests, integration tests, or runtime validation at trust boundaries. The teams I've seen burn out on TypeScript are the ones who treated it as a substitute for testing rather than a complement.

A Decision Heuristic

If you want a one-paragraph rule: use TypeScript by default for anything you'd ship to production or expect to maintain past a quarter. Use plain JavaScript with // @ts-check and JSDoc for scripts and small utilities where the build step would be the largest file. Use plain plain JavaScript — no types at all — for one-shot code, learning exercises, and anything that fits in a single screen.

The mistake is treating this as religion. TypeScript is a tool with a cost and a benefit. Most of the time the benefit wins. Sometimes it doesn't. Knowing the difference is the actual skill.

For more on related decisions, see REST API Design Best Practices on shaping the data your types describe, JSON Basics and Syntax for the format that crosses every boundary, and the MDN JavaScript reference for anything the compiler can't tell you.

FAQ

Will TypeScript ever be runnable directly in browsers without compilation?

Probably not in the foreseeable future. The Type Annotations proposal (Stage 1 in TC39 as of 2024) would let JavaScript engines ignore type syntax rather than execute it, which means you could ship .ts-shaped files but you'd still need a compiler for type checking. Even if that proposal lands, compile-time checking remains a separate step — Node.js 22+ already strips type annotations, but it doesn't validate them.

Is `any` actually that bad?

Yes — any is contagious because it disables type checking for every operation involving the value, not just the value itself. Once result: any exists, result.foo.bar.baz type-checks fine even if it crashes at runtime. Prefer unknown for "I don't know the type yet" and narrow with if (typeof x === "string") or a Zod parse. Reserve any for migration paths where you genuinely will fix it later.

What's the difference between `interface` and `type` in TypeScript?

For describing object shapes they're nearly interchangeable. The differences: interface supports declaration merging (multiple interface User declarations in scope merge fields), while type doesn't. type supports unions, intersections, mapped types, and conditionals; interface doesn't. Practical rule: use interface for object shapes you might extend, use type for unions and computed types. Don't sweat the choice for simple cases.

Should I enable `strict: true` from day one on a new project?

Yes for new projects, no for migrations. On a fresh codebase, strict: true enables strictNullChecks, noImplicitAny, and several other checks that catch real bugs. The cost is small because you're writing types from scratch anyway. On a migration, enabling strict mode at the same time as moving from JS adds too much noise; turn it on after the conversion completes.

Is Deno's TypeScript support better than Node's?

Different priorities. Deno runs TypeScript natively (no compile step) and ships with strict typing of standard libraries. Node.js 22+ strips type annotations natively but doesn't type-check them — you still need tsc or a watcher for that. For experimental scripts and small tools, Deno's experience is smoother. For large production apps with established build pipelines, Node + tsx + esbuild remains the common path.

Why does my IDE show types but the build doesn't catch the same errors?

Almost always a config drift between IDE and build. VS Code uses your tsconfig.json automatically, but build tools (Vite, esbuild, ts-loader) sometimes ship their own defaults that override strict or noImplicitAny. Verify both surfaces use the same config — the easiest check is running tsc --noEmit from the command line and comparing its output to what VS Code reports. Discrepancies usually mean a path mapping or excluded file mismatch.

Does TypeScript slow down build times noticeably?

Yes, but it's usually fixable. Plain tsc is slow on large codebases — use esbuild or swc for compilation (10–100× faster) and run tsc --noEmit only for type checking, ideally in parallel with the build. Project references and incremental compilation help too. If your build is slow, the bottleneck is rarely TypeScript itself; it's usually the bundler or test runner.

Can I use TypeScript types to enforce business rules at runtime?

No — types disappear at compile time. Branded types (type UserId = number & { __brand: "UserId" }) and literal unions help at compile time but don't carry into runtime. For runtime enforcement you need a validator (Zod, Valibot, ArkType). The common pattern: define a Zod schema, derive the TypeScript type with z.infer, validate at network boundaries, and trust the type internally.