TypeScript is a checker that runs over JavaScript and then gets out of the way. Every type you write is erased before the code runs, so the language buys you one thing: mistakes reported at your desk instead of in production. Almost all the confusion beginners hit comes from expecting types to exist at runtime, or from expecting the checker to be stricter than it is by default. What follows is the shape: what it checks, what it deliberately does not, and where it hands you the loaded gun.
Each chapter opens with the short version. Tap one to read the detail.
What TypeScript is, and what any costs
~2 min
Types are erased at compile time. Prefer inference over annotations, and treat any as switching the checker off for that value, which is why unknown is usually the type you actually wanted.
Write an annotation and the compiler checks it, then deletes it. Nothing you write in the type layer can be consulted at runtime, which is why "how do I check the type at runtime?" always resolves to ordinary JavaScript — typeof, a property check, a tag field you put there yourself.
Most annotations are unnecessary. The compiler infers from the initializer, and inference is usually more precise than what people write by hand. Annotate the boundaries — function parameters, exported signatures, empty containers — and let inference handle the rest.
The trap is any. It is not "a type I haven't decided yet"; it disables checking for that value and everything reached through it, silently, with no marker at the call site. One any returned from a helper deletes safety across every caller.
unknown is the honest version: it accepts anything but permits nothing until you narrow it.
const raw: unknown = JSON.parse(input);
// raw.name // error — good, you have not checked yet
if (typeof raw === 'object' && raw !== null && 'name' in raw) {
// now it is safe to read raw.name
}
The two other types worth knowing early: never is the type with no values (an exhausted union, a function that never returns), and void means "ignore the return", not "returns nothing".
Rule: annotate boundaries, infer everywhere else, and reach for unknown whenever you are tempted by any.
Structural typing: shape decides, not names
~2 min
Compatibility is decided by structure, not by declared names — anything with the required members fits. Two rules bolted on top of that surprise people: object literals get an extra check, and a value with more properties is normally fine.
A type is not an identity here. If a value has the members the target requires, it is assignable, whether or not anyone declared a relationship. A class you never linked to an interface satisfies it as long as the shape lines up.
Extra properties are normally fine — a value with more members than required still fits, because the target's members are all that get checked. But an object literal assigned directly gets an extra check that rejects unknown properties, on the theory that a stray key in a literal is almost always a typo.
That inconsistency is the thing to internalize, because the workaround people reach for by accident is assigning through a variable first, which silently passes.
type Pet = { name: string };
const dog = { name: 'Lassie', owner: 'Rudd' };
const a: Pet = dog; // fine — extra members allowed
const b: Pet = { name: 'Lassie', owner: 'Rudd' }; // error — literal, excess property
Inference picks the widest useful type: let on a string infers string, const infers the literal 'hello'. When you need the narrow reading from a mutable structure, as const freezes it.
One deliberate hole: function parameters are checked loosely by default, so a handler expecting a narrower argument is accepted where a broader one is required. It is unsound and known to be — strictFunctionTypes tightens it for function type positions.
Rule: think in shapes, not names — and remember the literal is checked more strictly than the variable.
Describing object shapes
~2 min
Optional, readonly, and index signatures cover most object modeling. Both modifiers are shallower than they look: readonly stops reassignment but not mutation, and an optional property is not the same as one that may be undefined.
Objects are described with interfaces or type aliases, and for object shapes the two are near-interchangeable. Interfaces can be reopened and extended; aliases cannot be reopened but can express unions and computed types.
Three modifiers do the work. ? marks a property optional. readonly blocks reassignment. An index signature describes a shape whose keys are not known ahead of time.
Both modifiers are shallower than people assume. readonly prevents replacing the property, not mutating what it points at — a readonly array property can still have items pushed into it. And optional means the key may be absent, which is subtly different from a key that is present holding undefined — 'timeout' in c answers differently for the two. Plain strict does not tell them apart: { retries: 3, timeout: undefined } is accepted for a timeout?: number property. Only the separate exactOptionalPropertyTypes flag makes the compiler enforce the definition as written.
For combining shapes, extends on an interface reports a conflict as an error, while an intersection with & merges conflicting members into an impossible type that fails only later, at the point of use. The error you get from extends is the one you want.
Rule: reach for extends over & when combining object shapes, so conflicts surface where they are introduced.
Functions, and why overloads are usually a mistake
~2 min
Parameters and return types are the boundary worth annotating. Overloads look like the tool for a function accepting several shapes, but a union parameter is almost always simpler and gives better errors.
A function type is written as an arrow, and that is enough for most callbacks. When a function also carries properties, an object type with a call signature describes it.
Generic functions infer their type parameters from the arguments, so callers rarely pass them explicitly.
Overloads are the feature people misuse. You write several signatures and one implementation, and the implementation signature is not callable from outside — a fact that produces confusing errors when the visible signatures do not cover a real call.
function len(x: string | unknown[]): number {
return x.length;
}
That is better than two overloads for the same job: the union states the contract directly, and a wrong call gets an error about the argument rather than a list of signatures that did not match. Reserve overloads for the genuine case where argument count changes the return type in a way a union cannot express.
Return types are worth annotating on exported functions even though they can be inferred, because an accidental widening then fails at the definition instead of at some distant call site.
Rule: try a union parameter first; use an overload only when the return type depends on the arguments.
Narrowing: how a union becomes one type
~2 min
The checker follows your control flow and narrows a union as you test it. Discriminated unions are the pattern that makes this reliable, and never turns an unhandled case into a compile error instead of a silent fall-through.
Test a value and the compiler tracks what it must be on each branch — typeof for primitives, truthiness, equality, in for properties, instanceof for classes.
The famous trap is inherited from JavaScript: typeof null is 'object', so a check for an object does not exclude null. Truthiness has a matching hazard, because empty string and zero are falsy, so a bare truthiness check quietly rejects legitimate values.
The reliable pattern is a discriminated union — a shared literal field the compiler can switch on.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
}
}
Add a default branch assigning the value to never, and adding a third member to the union turns every unhandled switch into a compile error — the single highest-value trick in the language.
When a check is too complex for the compiler to follow, a type predicate (function isFish(x: Pet): x is Fish) lets you name the conclusion. It is a promise the compiler trusts rather than verifies, so a wrong predicate is a hole you dug yourself.
Rule: give unions a literal tag, and let never tell you when a new case appears.
Generics and the keyof family
~2 min
A generic relates two types to each other; if a type parameter appears only once, you did not need one. Constraints with extends are what let you use a value you have not seen yet, and keyof ties a key argument to the object it indexes.
The point of a type parameter is to carry a relationship: the argument's type and the return type are the same, or one parameter's keys come from another's shape. If a parameter appears in exactly one position, it is doing nothing a plain type could not do.
Constraints let you require structure without fixing the type. extends restricts what may be substituted, and inside the function you may use whatever the constraint guarantees.
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const name = pick({ name: 'Ada', age: 36 }, 'name'); // string
The example shows the operator family: keyof gives the union of an object's keys, indexed access T[K] gives the type at a key, and typeof in type position lifts a value's inferred type back into the type layer.
Two habits to avoid. Over-constraining — writing a constraint for a requirement you do not actually have — makes a function refuse valid callers. And assertions (as) are not conversions: they silence the checker without changing anything at runtime, so a wrong assertion produces exactly the crash the checker existed to prevent. The non-null assertion ! is the same bargain in miniature.
Rule: if a type parameter is used once, delete it — and treat every as as a claim you are personally guaranteeing.
Types that compute types
~2 min
Conditional, mapped, and template literal types let one type be derived from another, and the built-in utilities are ordinary code written that way. The behavior that catches everyone is distribution: a conditional type applied to a union runs once per member.
The type layer can compute. A conditional type chooses between two results, infer captures a piece of a matched shape, and a mapped type rebuilds an object by transforming each key.
The utilities you already use — Partial, Pick, Omit, Record, ReturnType — are written with exactly these tools.
The gotcha is distribution. A conditional type over a naked type parameter applies separately to each member of a union, and the results are unioned back together.
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
type NoDist<T> = [T] extends [unknown] ? T[] : never;
type B = NoDist<string | number>; // (string | number)[]
Wrapping both sides in tuple brackets turns distribution off. When a conditional type produces a union you did not expect, this is nearly always the reason.
Keep computed types shallow. They are debuggable only through the error messages they produce, and a deeply nested chain reports failures at the outermost layer with no indication of which inner step broke.
Rule: when a computed type returns a union you did not ask for, suspect distribution first.
tsconfig: the flags that decide how much checking you get
~2 min
The same code is checked very differently depending on configuration, and strict is the switch that matters. Without strictNullChecks the compiler ignores null and undefined entirely, which removes the most valuable checking it does.
Two assumptions to drop on day one: that a .ts file is checked strictly out of the box, and that a type error stops the build. Neither holds by default. tsconfig.json decides both what is checked and what is emitted, and the defaults are permissive for backwards compatibility. strict enables the family of checks that make the language worth using, including noImplicitAny, strictNullChecks, strictFunctionTypes, and strictPropertyInitialization.
strictNullChecks is the load-bearing one. With it off, null and undefined are assignable to everything and the compiler will happily let you read a property off a value that is not there — the exact class of crash the type system is supposed to prevent.
noImplicitAny catches the other silent hole: a parameter with no annotation quietly becoming any, so a typo inside the function goes unreported.
Worth enabling deliberately beyond strict: noUncheckedIndexedAccess, which admits that indexing a record with an arbitrary key might return nothing.
// with noUncheckedIndexedAccess
const env: Record<string, string> = process.env as never;
const v = env.HOME; // string | undefined, not string
Separately, remember the compiler has two jobs — checking and emitting — and they are independent. By default a project with type errors still emits JavaScript — tsc exits non-zero and writes the .js anyway; noEmitOnError couples them.
Rule: turn strict on at the start of a project, because retrofitting it later is a migration rather than a setting.
Modules and what ships to consumers
~2 min
A file with a top-level import or export is a module; without one it is a script sharing the global scope. import type is erased entirely, which is what keeps type-only dependencies out of your runtime bundle.
The assumption to drop: that every .ts file is its own scope. Module status is decided by the file's contents, not its location. A file with no top-level import or export is treated as a script, and its declarations land in the global scope — which is why two unrelated files each declaring const shared report Cannot redeclare block-scoped variable against each other. Adding export {} makes it a module and the collisions disappear.
Two settings govern the rest. Module resolution decides how a specifier maps to a file on disk. The module setting decides the shape of the emitted JavaScript. They are independent, and mismatching them produces imports that type-check and then fail at runtime.
Type-only imports are worth a habit. A value import survives compilation; a type import is erased.
import type { User } from './types'; // erased entirely
import { formatUser } from './format'; // kept — real runtime dependency
That distinction matters when a module is imported purely for its types: without the type keyword, the emitted code keeps a real import of a module you never use at runtime.
If you publish a package, its declaration files are the contract consumers see. They are generated from your source, so an accidentally exported internal type becomes part of your public surface.
Rule: mark type-only imports as such, and read the declarations you publish as the public interface they are.
Classes, and the privacy that is not private
~2 min
Classes are the JavaScript feature with type annotations layered on. The one thing to know cold: private is checked by the compiler and gone at runtime, while #name is genuinely inaccessible.
Fields, constructors, and inheritance behave as they do in JavaScript. What the type layer adds is annotations, access modifiers, and an initialization check.
strictPropertyInitialization requires every declared field to be assigned inline or in the constructor, which catches the class that looks complete and holds undefined in a field typed as present.
The mistake that hurts is treating private as a security boundary. It is not one: private and protected are compile-time only — the property is an ordinary key at runtime and reachable by anyone indexing the object. The JavaScript #name syntax is enforced by the runtime.
class Session {
private soft = 'visible at runtime';
#hard = 'genuinely inaccessible';
}
const s = new Session();
(s as never)['soft']; // reachable — private is a compile-time contract only
If privacy is a security boundary rather than a convention, # is the only version that holds.
implements and extends do different jobs: implements only checks that the class satisfies a shape and adds nothing, while extends actually inherits. Parameter properties — a modifier on a constructor parameter — declare and assign a field in one step, which removes most boilerplate constructors.
Decorators and mixins exist for cross-cutting behavior, and both are worth reaching for only after a plain function has failed you.
Rule: use # when privacy must survive runtime, and private when it is documentation for your team.
Enums, and the modern alternative
~2 min
An enum is the rare TypeScript feature that emits real runtime code rather than being erased. A union of string literals does the same job with no runtime footprint.
Almost everything in the language disappears at compile time. Enums do not: a numeric enum emits an object with a two-way mapping, so you can look up the name from the value.
Numeric enums auto-increment from zero, which makes an unset field indistinguishable from the first member and lets any number be assigned in some configurations. String enums avoid both problems and serialize readably.
The modern alternative is a union of literals, optionally with a frozen object when you need the values enumerable.
const Direction = { Up: 'UP', Down: 'DOWN' } as const;
type Direction = typeof Direction[keyof typeof Direction]; // 'UP' | 'DOWN'
That has no runtime cost beyond the object you chose to keep, works with plain JavaScript consumers, and narrows exactly like any other union. const enum inlines members and erases the object, but it interacts badly with separately compiled packages, so it is not the easy win it looks like.
Iteration is adjacent and worth knowing: a for...of loop needs an iterable, and whether your target supports iterating a Map or Set depends on compilation settings rather than on the type layer.
Rule: default to a union of string literals; reach for an enum only when you genuinely need the runtime object.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.