There is a persistent misunderstanding about what TypeScript is for. People describe it as a way to catch bugs, and when they discover that a TypeScript program can still crash at run time they conclude it was oversold. Both halves of that are wrong in an interesting way. TypeScript is not a safety net around your running program. It cannot be: every type annotation you write is erased before the code executes. What you ship to a browser is JavaScript, with no types in it at all. There is no run-time checking, no enforcement, nothing watching. What TypeScript actually does is force you to write down what you already believed, and then check whether you believed it consistently. That sounds like a smaller claim. In practice it is a much more useful one, because the bugs it catches are not exotic — they are the ordinary consequences of two parts of a codebase disagreeing about the shape of some data, and those are the bugs that are hardest to find by reading.
Types are documentation that cannot go stale
Every function has an implicit contract. It expects certain arguments, in a certain shape, and returns something particular. In plain JavaScript that contract lives in three unreliable places: a comment, a variable name, and the author's memory. function applyDiscount(order, percent) { … } What is an order? Does percent mean 20 or 0.2? What comes back — a new order, or the same one mutated? You can answer all three by reading the body, and you will have to, every time, and so will everyone else. function applyDiscount(order: Order, percent: number): Order { … } Now two of the three are answered at the call site. The third — 20 or 0.2 — is still ambiguous, which is worth noticing, because it tells you something important: types do not capture everything, and pretending otherwise is its own mistake. A number does not say "a percentage between 0 and 100". If that distinction matters, you can encode it, but the plain annotation has not done it for you. The real advantage over a comment is that the comment can be wrong. A comment that says "returns an Order" survives happily after somebody changes the function to return null on failure. A type annotation does not — the compiler rejects the mismatch immediately.
Inference means you write fewer types than you expect
Beginners over-annotate, and the result is noisy code that is harder to read than the JavaScript it replaced. const name: string = 'Ama'; // the annotation adds nothing const items: number[] = [1, 2, 3]; // nor here TypeScript already knows. The rule worth following: annotate the boundaries, let the middle infer. Function parameters and return types, exported constants, and the shape of data arriving from outside — those are worth stating, because they are the contracts. Local variables inside a function almost never are. Annotating return types is a point of genuine disagreement among experienced people. Inference handles it well, but writing the return type explicitly has one real benefit: if your implementation drifts from what you intended, the error appears in the function you broke rather than in the five places that call it. On an exported function, that is usually worth the three extra characters.
interface, type, and the difference that matters
Both describe the shape of data, and for most object types they are interchangeable. The practical differences are few. interface can be reopened — declare it twice and the declarations merge. That is occasionally useful for extending a library's types, and occasionally a source of confusion when two files unexpectedly contribute to the same interface. type can express things interface cannot: unions, intersections, mapped types, conditional types, and aliases for primitives or tuples. A union in particular is where TypeScript's design starts to pay off. A reasonable convention, and one most teams land on: interface for object shapes you expect others to extend, type for everything else. The important thing is to pick one and be consistent, because the cost of mixing them arbitrarily is that readers start looking for a meaning in the choice that is not there.
Unions and narrowing: the idea that makes TypeScript worth it
A union says a value is one of several things. type Result = { ok: true; data: User } | { ok: false; error: string }; This is a discriminated union, and it is the single most valuable pattern in the language. The ok field is the discriminant, and checking it narrows the type: function handle(result: Result) { if (result.ok) { console.log(result.data.name); // TypeScript knows data exists here } else { console.error(result.error); // and that error exists here } } Inside the first branch, result.error is a compile error. Inside the second, result.data is. The compiler is tracking which case you are in and refusing to let you reach for a field that does not exist in that case. Compare that with the JavaScript convention of returning an object with optional data and optional error and hoping the caller checks the right one. The union makes the invalid combinations unrepresentable, which is a much stronger guarantee than a convention everyone agrees to follow. Narrowing happens through ordinary JavaScript: typeof for primitives, instanceof for classes, in for property presence, truthiness checks, and equality against literal types. You are not learning a new query language — you are writing the checks you would have written anyway, and TypeScript is reading them.
any is an escape hatch, and it spreads
any turns checking off for that value. It is sometimes the right answer — genuinely dynamic data, an untyped library, a migration in progress — but it has a property people underestimate: it is contagious. Anything derived from an any is also any, so one annotation can quietly disable checking across a whole call path, and nothing warns you. unknown is the honest alternative. It also means "I do not know what this is", but it refuses to let you do anything with the value until you have checked. That one difference turns a silent hole into a prompt to write the validation you were skipping. Turning on noImplicitAny in your configuration is the single highest-value setting in the language, because it finds the anys you did not write deliberately — the ones that arrived because a parameter had no annotation and TypeScript gave up quietly.
Strict mode, and strictNullChecks in particular
Without strictNullChecks, null and undefined are assignable to every type, which means the type system is lying to you about the most common run-time error in all of JavaScript. With it on, string means a string and string | null means it might not be there — and the compiler insists you handle the second case before using the value. This is the setting that converts TypeScript from a nice autocomplete experience into something that actually catches bugs. It is also the setting that produces a wave of errors when enabled on an existing codebase, which is why so many projects leave it off and then wonder why TypeScript "doesn't catch anything". The whole strict family is worth enabling on any new project from the first commit. Enabling it later is possible but it is a project, not a checkbox.
Generics are parameters for types
A generic lets a function work with many types while preserving the relationship between them. function first<T>(items: T[]): T | undefined { return items[0]; } first([1, 2, 3]) returns number | undefined. first(users) returns User | undefined. Without the generic you would either write the function once per type, or type it as any[] and lose the return type entirely. That is the whole idea: a generic preserves information that would otherwise be thrown away. When you find yourself writing any because a function is reused across types, a generic is usually the answer. Generics get a reputation for being advanced because library type definitions use them heavily and at considerable depth. Writing your own rarely needs that. If a generic you are writing has three parameters and a conditional type, it is worth asking whether a plain union would serve the readers of your code better.
The type system does not exist at run time, and this matters
This is the point that catches everybody at least once. interface User { name: string; }
function isUser(x: unknown): x is User { return typeof x === 'object' && x !== null && 'name' in x; // a real check } You cannot write if (x instanceof User) — there is no User at run time to compare against. Interfaces are erased completely. The consequence is sharpest at the edges of your program. When you call await response.json(), TypeScript will cheerfully let you annotate the result as User, and it will be wrong the moment the API changes. That annotation is an assertion, not a check — you are telling the compiler to trust you, and the compiler has no way to verify it. For data crossing a boundary — network responses, form input, parsed files, anything from localStorage — a real run-time validation step is the only thing that makes the type honest. Either hand-written type guards like the one above, or a schema library that produces both a validator and a type from one declaration. Without that, your types describe what you hoped the API would send.
Reading the error messages
TypeScript's errors are precise and sometimes long. The habit that makes them tractable: read the last line first. The compiler works outward from the specific mismatch, so a long message usually ends with the actual problem — "Type 'string' is not assignable to type 'number'" — while the lines above explain which nested property it was reached through. For a deeply nested mismatch, assigning the value to an intermediate variable with an explicit type will often relocate the error to the exact field, which is far easier to read than a twelve-line structural diff.
What TypeScript costs
It is worth being even-handed about this, because the trade is real. There is a build step, which there may not have been before. There is a compile time that grows with the project. There is a learning cost, concentrated in the first few weeks and in library type definitions that are considerably more advanced than anything you will write. And there is a temptation to spend time making types elegant when the time would be better spent on the program. What you get in return is proportional to how much the data in your program moves between modules and people. On a fifty-line script, very little. On a codebase several people touch over a year, a great deal — because the expensive bugs there are not logic errors, they are two parts of the system disagreeing quietly about a shape, and that is exactly the category TypeScript makes loud.
The utility types worth knowing before you write your own
TypeScript ships a set of built-in type transformations, and four of them cover most real needs. Knowing them stops you hand-writing near-duplicate interfaces that then drift apart. Partial<T> makes every property optional, which is exactly the shape of an update payload: a patch that may carry any subset of the fields. Required<T> does the reverse. Pick<T, K> selects named properties, and Omit<T, K> removes them — the usual way to say "a User, but without the password hash" without defining a second interface that somebody will forget to update when User changes. That last point is the real argument for them. Every type you derive from another is a type that cannot drift. Write Omit<User, 'passwordHash'> and adding a field to User automatically adds it here too. Write a separate PublicUser interface by hand and the two will disagree within a few months, silently, in whichever direction is least convenient. Record<K, V> builds an object type from a key type and a value type, which is how you type a lookup table whose keys you know: type Status = 'draft' | 'published' | 'archived'; const labels: Record<Status, string> = { draft: 'Draft', published: 'Published', archived: 'Archived' }; Add a fourth status later and this object fails to compile until you add its label. That is a small example of a generally valuable technique: arranging your types so that forgetting a case is a compile error rather than a missing string on a page.
The configuration file is part of the language
A TypeScript project's behaviour is set by tsconfig.json, and two people can have the same code with wildly different levels of checking. It is worth knowing what the important switches do rather than copying a config and hoping. strict is the master switch, turning on roughly eight individual checks including strictNullChecks and noImplicitAny. target decides which JavaScript version is emitted, which affects what syntax is downlevelled. moduleResolution decides how imports are located, and is the usual culprit when an import that obviously exists cannot be found. Two more are worth turning on deliberately. noUncheckedIndexedAccess makes arr[0] have type T | undefined rather than T, which is honest — index access genuinely can miss — and catches a real class of bug at the cost of some extra checks. exactOptionalPropertyTypes distinguishes "property absent" from "property present and undefined", which matters more than it sounds when you are serialising to JSON.
What to practise
- Convert a small JavaScript file with strict on from the start. Fix the errors honestly rather than with any — most of them are real ambiguities you had been carrying.
- Model one state machine as a discriminated union — loading, loaded, failed — and let the compiler stop you reading data while still loading. This is the exercise that makes the value obvious.
- Write a type guard for an API response and use it after fetch. Then change the shape and watch where the error appears.
- Take something typed
anyand replace it with unknown, then add the checks the compiler asks for. You will usually find one case nobody had handled. TypeScript rewards the person who treats it as a thinking tool rather than a formality. The question it keeps asking is "what exactly can this value be?" — and most of the time, answering that question honestly is the work.