TypeScript, the good, bad and ugly

JavaScript that finally reads the manual

JavaScript is famous for being the language that lets you do anything, including all the things you absolutely should not. TypeScript is JavaScript that has finally sat down and read the manual: same language, same runtime, but with a type system bolted on top that catches your mistakes before your users do. It has gone from a curiosity to the default way large teams write JavaScript, and for good reason. Continuing our series on programming languages, here is TypeScript in three acts.

TypeScript is a superset of JavaScript. Every valid JavaScript file is already valid TypeScript, which is the masterstroke that made adoption possible: you can rename a .js file to .ts and it still works. On top of that base, TypeScript adds static types that the compiler checks at build time and then erases entirely. The output is plain JavaScript with all the type annotations stripped out, because browsers and Node.js have never heard of types and never will. That single design decision, types that exist only at compile time, explains both its greatest strengths and one of its sharpest limitations.

The headline benefit is static typing over a language that desperately needed it. JavaScript will happily let you call a method that does not exist, pass a string where a number belongs, or read a property off undefined, and you will only find out at runtime, usually in production, usually on a Friday. TypeScript moves that discovery to the moment you type the code:

function greet(user: { name: string }) {
  return `Hello, ${user.name.toUpperCase()}`;
}

greet({ nme: "Sam" }); // Error: 'nme' does not exist; did you mean 'name'?

That typo is caught before the code ever runs. Multiply that across a large codebase and the saved debugging time is enormous.

The tooling is the second great virtue, and arguably the real reason people fall in love with it. Because the editor understands your types, autocomplete becomes genuinely useful, “rename symbol” works reliably across an entire project, and you can refactor with the confidence that the compiler will flag everything you broke. Hovering over a value tells you its exact shape. This is the kind of editor experience that dynamic languages can only approximate.

Adoption can be gradual, which matters for real codebases. You do not rewrite your application; you turn on TypeScript, let everything be loosely typed at first, and tighten the screws over time. Combined with a vast ecosystem of type definitions for popular libraries, you get strong typing without abandoning the npm world you already live in.

The price of all this is a build step, and with it a configuration burden. JavaScript runs as written; TypeScript must be compiled, which means a toolchain, a tsconfig.json, and a set of decisions about targets, module systems, and bundlers that you would rather not be making. For a small script, the ceremony can outweigh the benefit.

The type system, powerful as it is, can grow baroque. What starts as a tidy interface can metastasise into a thicket of generics, conditional types, and mapped types that nobody on the team fully understands:

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

That is genuinely useful, but it is also the gateway drug to type definitions that are harder to read than the code they describe.

Then there is the escape hatch. The any type switches off type checking entirely for whatever it touches, and it is seductive precisely when you are under pressure and the compiler is in your way. A single stray any can quietly disable safety across a surprising amount of code, and codebases tend to accumulate them in exactly the gnarly corners where types would have helped most.

Finally, and this trips up newcomers constantly: the types are gone at runtime. TypeScript proves things about your code before it runs, but it provides no runtime guarantees whatsoever. Data arriving from an API, a form, or a database has not been checked by anyone, no matter how confidently you typed it.

The genuinely painful parts of TypeScript live in three places. The first is tsconfig archaeology. The configuration file has accumulated dozens of options over the years, with interactions that are not obvious, and inheriting a project’s config often feels like reading the ruins of decisions made by people who have since left. Flip strict on and a previously green project lights up red; leave it off and you are not getting half the value.

The second is type gymnastics. Because the type system is itself a small, Turing-complete language, people write programs in it. When a library’s types are clever enough, an error message can run to forty lines of nested generic soup that tells you something is wrong without telling you what or where. You came to write a web form; you are now debugging a proof.

The third is the runtime gap made concrete. Because types vanish, you must validate untrusted data yourself, and the temptation is to lie to the compiler instead:

const data = JSON.parse(response) as User; // a promise, not a check

That as does nothing at runtime. If the JSON does not match User, nothing complains until something blows up three functions later. The honest fix is a runtime validation library, which means describing your shapes twice, once for the compiler and once for reality, unless you adopt a tool that derives both from a single schema.

There is also structural typing to surprise you. TypeScript decides two types are compatible if their shapes match, not their names, so a function expecting a Point will happily accept any object that happens to have the right fields, even if it was meant to be something else entirely. Usually convenient, occasionally the source of a bug that no amount of staring at the type definitions will explain.

The single most valuable habit for serious TypeScript is to validate data at the edges of your program and trust your types everywhere inside. Anywhere untrusted data enters, an HTTP response, a parsed file, a form submission, you should check its shape at runtime rather than asserting it with as. Schema validation libraries make this pleasant by letting you describe a shape once and derive both the runtime check and the static type from the same definition:

const User = z.object({ name: z.string(), age: z.number() });
type User = z.infer<typeof User>;

const user = User.parse(JSON.parse(response)); // throws if the data lies

Now the compiler’s promise and reality agree, because the data has actually been checked. Adopt that pattern at every boundary and the runtime gap, the sharpest of TypeScript’s ugly edges, mostly closes. Inside that validated core you can lean on the type system with confidence; it is only at the doorways to the outside world that types alone are not enough.

TypeScript is one of the most successful programming-language additions of the last decade, and the enthusiasm is earned. For anything beyond a throwaway script, the combination of early bug detection and first-class tooling pays for itself many times over, and the gradual adoption path means you rarely have to commit all at once. Large teams in particular gain a shared, machine-checked contract about what their code expects, which is worth a great deal when dozens of people are editing the same codebase.

The discipline that keeps it pleasant is restraint. Turn on strict and live with the early pain, treat every any as a small debt to be repaid, validate data at the boundaries instead of asserting it with as, and resist the urge to win awards with your generics. Remember always that the compiler protects you up to the moment your program starts running and not one millisecond after. Used with that humility, TypeScript is JavaScript that has finally read the manual and, mostly, follows it. Used without it, you get all the build complexity and a comforting illusion of safety that the runtime is under no obligation to honour.