If you have ever shipped a JavaScript app of any real size, you know the moment. It is 11pm. You renamed a function from getUser to loadUser. The app boots. Tests pass. You ship it. Twenty minutes later a coworker pings you: a button somewhere completely unrelated has stopped working, because something deep inside still called getUser and the editor never warned you.
JavaScript is full of these little knives. undefined is not a function. Cannot read property 'name' of null. A string that quietly was a number. An object missing a field. The compiler did not catch it because there was no compiler.
That is the gap TypeScript fills.
What is TypeScript, really
Think of TypeScript as a label maker for JavaScript values. In plain JS, every box in your warehouse looks the same: an unlabeled cardboard cube. You have to open it to know what is inside, and you might still be wrong. In TS, every box gets a sticker on the front that says exactly what kind of thing it holds. "string". "User". "Array of pizzas". A picky little manager called the type checker walks the aisles and yells at you if you try to put a tomato in the box labeled "books".
That manager only works at build time. Once you ship, the labels are stripped off and the boxes go to production as plain JS. So you pay zero runtime cost for all that safety.
That is the whole vibe.
Let's pretend we are building one
We want to add a type system on top of JavaScript without breaking anything that already works. We will call it TypeScript. As we design it, every decision is a thing you will run into in real code, so let's make them on purpose.
For the running example, we are opening a tiny pizza shop. Pizzas, toppings, sizes, orders. We will type the whole thing as we go.
Decision 1: Types are notes for the compiler, not new runtime stuff
We do not want to invent a new language. We want JavaScript with extra notes on top. So our compiler will read the notes, check them, and then erase them. The JS that ships to production is the same JS you would have written without types.
function add(a: number, b: number): number {
return a + b;
}
After compilation, the file becomes:
function add(a, b) {
return a + b;
}
The labels are gone at runtime. This has two huge consequences that newcomers miss:
-
You cannot trust types at runtime. If data comes from an API, a form, or
JSON.parse, the type system has no idea what is actually in there. You have to validate it (with Zod, Valibot, or a hand written guard). -
No new runtime features (mostly). Old TypeScript tried with
enum, decorators, andnamespace. The modern advice is to avoidenumand prefer plain unions of strings.
Decision 2: Start with the basics, give two escape hatches
Our type system needs the obvious primitives, plus a way to say "I do not know yet" without giving up on safety entirely.
let title: string = "Margherita";
let price: number = 9.5;
let isOpen: boolean = true;
let nothing: null = null;
let missing: undefined = undefined;
let everything: any = "literally anything"; // do not use, opts out of safety
let mystery: unknown = "could be anything"; // safe version of any
function explode(): never { throw new Error("boom"); } // function never returns
function log(msg: string): void { console.log(msg); } // does not return a value
Two escape hatches matter a lot:
-
anyturns off the type checker for that value. Treat it like a swear word. The whole point of TS is to know what you have, andanysays "I do not know and I do not want to". Use only as a last resort, and isolate it. -
unknownalso says "I do not know yet", but the compiler refuses to let you do anything with it until you prove what it is (with a check or a guard). This is the right tool for incoming data.
function readInput(value: unknown) {
// value.toUpperCase() // error: object is of type 'unknown'
if (typeof value === "string") {
value.toUpperCase(); // ok now, narrowed to string
}
}
never is the bottom type, the type with no values. It shows up when something is logically impossible (a function that always throws, or a switch branch that should never be reached). We will use it as a tool later for exhaustiveness.
Decision 3: Describe object shapes with type or interface
A pizza is not just a string. It is a shape with a name, a size, toppings, and a price. We need to describe shapes.
type Pizza = {
id: string;
name: string;
size: "small" | "medium" | "large";
toppings: string[];
priceCents: number;
};
Two ways to write this exist, and they confuse newcomers. There are type aliases and interface declarations:
interface Pizza {
id: string;
name: string;
// ...
}
Practical advice in 2026:
-
Use
typeby default. It can do everythinginterfacecan, plus unions, intersections, mapped types, and conditional types. -
Use
interfacewhen you need declaration merging (mostly for adding fields to library types) or when you are publishing a public class API. - Be consistent inside one project. Do not flip flop.
A nice fact about TypeScript that surprises people coming from Java or C#: types are structural, not nominal. If two shapes look the same, they are the same. Names do not matter to the checker.
type Cat = { name: string; age: number };
type Dog = { name: string; age: number };
const meow: Cat = { name: "Mochi", age: 2 };
const woof: Dog = meow; // perfectly fine, shapes match
This is amazing for flexibility. Sometimes too flexible (we will fix that with branded types later).
A few small tricks worth knowing about object types:
type Pizza = {
readonly id: string; // cannot be reassigned after creation
description?: string; // optional, may be undefined
[key: string]: unknown; // index signature: any extra keys allowed
};
Decision 4: Combine, pick, choose
Real types are not always one shape. They are "this or that", or "this and that".
Union types: A | B
type Size = "small" | "medium" | "large";
function priceFor(size: Size): number {
if (size === "small") return 800;
if (size === "medium") return 1100;
return 1400;
}
Those "small" | "medium" | "large" are literal types. A literal type is a type whose only value is one specific value. This is how we get the "enum like" experience without the actual enum keyword.
Intersection types: A & B
type WithTimestamps = { createdAt: Date; updatedAt: Date };
type StoredPizza = Pizza & WithTimestamps;
A StoredPizza is everything a Pizza is, and has timestamps too.
Discriminated unions, the secret weapon
This is the single most useful pattern in real TypeScript. When something can be in multiple shapes depending on a state, give every variant a common literal field, and the checker can tell them apart.
type Order =
| { status: "pending"; pizzaId: string }
| { status: "cooking"; pizzaId: string; ovenId: string }
| { status: "delivered"; pizzaId: string; deliveredAt: Date }
| { status: "cancelled"; reason: string };
function describe(order: Order): string {
switch (order.status) {
case "pending": return `Pending pizza ${order.pizzaId}`;
case "cooking": return `Cooking in oven ${order.ovenId}`;
case "delivered": return `Delivered at ${order.deliveredAt.toISOString()}`;
case "cancelled": return `Cancelled: ${order.reason}`;
}
}
Inside each case, TypeScript narrows the type and gives you only the fields that exist for that variant. No more "is this property here on this kind of order? let me check the docs".
Add a final exhaustiveness check using never and the compiler will literally yell at you when a future you adds a new status and forgets to handle it:
function describe(order: Order): string {
switch (order.status) {
case "pending": return ...;
case "cooking": return ...;
case "delivered": return ...;
case "cancelled": return ...;
default: {
const _exhaustive: never = order;
return _exhaustive;
}
}
}
Add a new case to Order later, forget to add a branch, and that line lights up red. Free safety net.
Decision 5: Make types reusable with generics
We do not want to write a new shape for "an array of pizzas", "an array of orders", "an array of users". The shape is the same: an array, holding things of some type. So we let the user fill in the blank.
type Box<T> = { value: T };
const numberBox: Box<number> = { value: 42 };
const pizzaBox: Box<Pizza> = { value: somePizza };
The T is a type parameter. Read it as "for any type T, a Box of T is...".
Generics shine in functions:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const p = first([pizza1, pizza2]); // p is Pizza | undefined
const n = first([1, 2, 3]); // n is number | undefined
You did not have to tell first what type was in there. The compiler inferred it from the argument. Inference is one of TypeScript's superpowers. Lean on it. Do not annotate things the compiler can already figure out.
You can constrain a type parameter, so it has to satisfy some shape:
function getId<T extends { id: string }>(item: T): string {
return item.id;
}
Now T can be anything, as long as it has an id: string.
Real world examples you will see daily:
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
async function fetchPizza(id: string): Promise<ApiResult<Pizza>> { /* ... */ }
That single pattern (a generic discriminated union) replaces about 80% of "what should the return type be" questions.
Decision 6: Ship a standard library of utility types
Once we have generics, conditionals, and mapped types (more on those soon), we can pre build a bunch of "type level functions" that everyone needs. These come with the language.
The greatest hits:
type P = Partial<Pizza>; // every field optional
type R = Required<Pizza>; // every field required (strips ?)
type O = Readonly<Pizza>; // every field readonly
type N = NonNullable<string | null | undefined>; // string
type Just = Pick<Pizza, "id" | "name">; // only those keys
type Without = Omit<Pizza, "priceCents">; // every key except those
type Menu = Record<string, Pizza>; // an object map: keys -> Pizza
type Args = Parameters<typeof priceFor>; // tuple of arg types: [Size]
type Ret = ReturnType<typeof priceFor>; // number
type Awaited1 = Awaited<Promise<Pizza>>; // unwraps promises: Pizza
type SizeOnly = Extract<Size, "small" | "medium">; // narrowing a union
type NotSmall = Exclude<Size, "small">; // removing from a union
Memorize these. They make 90% of "how do I express this type" problems trivial.
Decision 7: Narrow types as you learn more
The compiler tracks what you know about a value as the code runs. Each check makes the type more specific. This is narrowing, and the rules are:
function area(x: string | number) {
if (typeof x === "string") {
x.toUpperCase(); // here x is string
} else {
x.toFixed(2); // here x is number
}
}
class Pizza { eat() {} }
class Salad { dress() {} }
function feed(food: Pizza | Salad) {
if (food instanceof Pizza) food.eat();
else food.dress();
}
function hasName(v: object): boolean {
if ("name" in v) return true; // narrows to { name: unknown } & object
return false;
}
For checks the compiler cannot figure out on its own, write a type predicate. It teaches the checker your custom logic:
function isPizza(x: unknown): x is Pizza {
return typeof x === "object" && x !== null && "toppings" in x;
}
const stuff: unknown = await fetchSomething();
if (isPizza(stuff)) {
stuff.toppings; // ok, narrowed to Pizza
}
Type predicates are how you bring messy outside data into your typed world. For real production code, prefer a runtime schema validator like Zod that gives you the predicate and the type at the same time:
import { z } from "zod";
const PizzaSchema = z.object({
id: z.string(),
name: z.string(),
size: z.enum(["small", "medium", "large"]),
toppings: z.array(z.string()),
priceCents: z.number().int().nonnegative(),
});
type Pizza = z.infer<typeof PizzaSchema>; // type derived from the schema
const pizza = PizzaSchema.parse(unknownInput); // throws if invalid
One schema, both runtime validation and the static type. This is the modern standard at the API boundary.
Decision 8: Let people compute new types from old ones
This is the part that makes TypeScript feel like a tiny programming language at the type level. Powerful, sometimes a little spooky.
Mapped types
Walk over the keys of one type and produce another.
type Optional<T> = { [K in keyof T]?: T[K] };
type ReadonlyDeep<T> = { readonly [K in keyof T]: T[K] };
type DraftPizza = Optional<Pizza>; // every field optional
Partial, Required, Readonly, and friends are all built with mapped types behind the scenes.
Conditional types
If A is a subtype of B, do this, else do that.
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hello">; // "yes"
type B = IsString<42>; // "no"
Combined with infer, you can pull pieces out of types:
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Topping = ElementOf<Pizza["toppings"]>; // string
Template literal types
Strings as types, with interpolation.
type EventName<T extends string> = `on${Capitalize<T>}`;
type X = EventName<"click">; // "onClick"
You can build wild route type DSLs with these (think "/users/:id" inferring the parameter names), but for daily work, the value is mostly readable string keys.
keyof and indexed access
type PizzaKeys = keyof Pizza; // "id" | "name" | "size" | "toppings" | "priceCents"
type SizeType = Pizza["size"]; // "small" | "medium" | "large"
These two operators are how you keep types in sync. If Pizza changes, anything derived from keyof Pizza updates automatically.
Decision 9: Three modern features that level you up
as const for "freeze this exactly"
By default, the compiler widens literal values. const x = "small" becomes type string. If you want the literal type, freeze it:
const SIZES = ["small", "medium", "large"] as const;
type Size = typeof SIZES[number]; // "small" | "medium" | "large"
That last line is gold. It builds the union type directly from the array, so you can never get them out of sync.
satisfies for "fits this shape, but keep the specific type"
A common annoying choice in old TS: do I annotate this as Record<string, Pizza> (and lose the specific keys), or leave it untyped (and lose the shape check)? satisfies solves both at once.
const menu = {
margherita: { id: "1", name: "Margherita", size: "small", toppings: [], priceCents: 800 },
pepperoni: { id: "2", name: "Pepperoni", size: "medium", toppings: ["pepperoni"], priceCents: 1100 },
} satisfies Record<string, Pizza>;
menu.margherita.name; // ok, autocomplete works
menu.unknownPizza; // error, key does not exist
The compiler verified each entry is a valid Pizza, but menu keeps its specific keys. Use satisfies whenever you would have reached for as. It is safer.
Branded types for nominal typing on the cheap
Structural typing means a UserId and a PizzaId are the same type if they are both strings. Sometimes you do not want that.
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type PizzaId = Brand<string, "PizzaId">;
function makePizzaId(s: string): PizzaId {
return s as PizzaId;
}
function deletePizza(id: PizzaId) { /* ... */ }
const userId: UserId = "u_42" as UserId;
deletePizza(userId); // error, not a PizzaId
deletePizza(makePizzaId("p_42")); // ok
The brand only exists at the type level. At runtime it is just a string. But the compiler will not let you mix them up. Use this any time a primitive (a string id, a number cents, a number millis) carries meaning that should not be mixed with another primitive of the same kind.
Decision 10: A tsconfig.json that prevents pain
The compiler is only as strict as you ask it to be. The settings below are what serious TypeScript codebases turn on. They prevent entire classes of bugs.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true, // turns on all the strict flags
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined, not T
"exactOptionalPropertyTypes": true, // x?: number does not allow x: undefined
"noImplicitOverride": true, // override must say so
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true, // helps with bundlers and SWC
"esModuleInterop": true,
"skipLibCheck": true, // faster builds, fewer 3rd party errors
"verbatimModuleSyntax": true // strict import type / export type
}
}
The two flags every senior should know about beyond strict:
-
noUncheckedIndexedAccessturnsarray[i]andrecord[key]intoT | undefined. Almost all "cannot read property of undefined" production bugs disappear when this is on, because the compiler now forces you to check. -
exactOptionalPropertyTypesstops{ name?: string }from secretly meaning{ name?: string | undefined }. Subtle but it bites at API boundaries.
Turn these on early in a new project. They are pure gain.
Decision 11: Talk to the rest of the world
Type only imports and exports
When you import only types, mark them. The bundler removes them entirely, no chance of a runtime side effect:
import type { Pizza } from "./types";
import { makePizza } from "./factory";
With verbatimModuleSyntax: true, the compiler enforces this. It also kills accidental cycles caused by importing a value just for its type.
.d.ts declaration files
A .d.ts file is types only, no implementation. You write them when:
- You publish a JS library and want to ship types separately.
- You have a JS file in your project that you want to type from outside.
- You want to add ambient types (like declaring
process.env).
// env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
NODE_ENV: "development" | "production" | "test";
}
}
}
export {};
Now process.env.DATABASE_URL is typed as string, and a typo in the variable name is a compile error.
Module augmentation and declaration merging
Sometimes a library is missing a type or you want to extend it. Reopen the module and merge in fields:
// express.d.ts
import "express";
declare module "express" {
interface Request {
user?: { id: string; email: string };
}
}
After this, req.user is typed everywhere, with no need to fork the package.
@types/* and DefinitelyTyped
If a JS only library does not ship types, look for @types/that-library on npm. It is published by the community DefinitelyTyped repo. If it does not exist, you can write a small .d.ts yourself with declare module "that-library"; to silence errors and add types as needed.
Senior level moves and traps
A handful of patterns and pitfalls you will run into at staff and senior level:
-
Prefer string unions over
enum. Enums have weird runtime behavior, do not tree shake well, and break withisolatedModules.type Status = "open" | "closed"is simpler and erased. -
Prefer
unknowntoanyeverywhere data crosses a boundary. Validate, then narrow. -
Avoid type assertions (
x as T) unless you genuinely know more than the compiler. Eachasis a tiny lie. Reach forsatisfies, type guards, or schema validators first. -
Variance matters more than you think. Function parameters are contravariant, return types are covariant. If
(a: Animal) => voidis assignable where(a: Cat) => voidis expected, that is why. Do not fight this, learn the rules. -
Distributive conditional types kick in over naked unions.
T extends X ? A : Bdistributes over each member ofTifTis a union. Wrap in[T]to disable:[T] extends [X] ? A : B. - Do not over engineer types. A 200 line conditional type that infers a SQL schema looks impressive in a tweet and is a maintenance nightmare in a real project. Go simple first. Reach for fancy types only when they pay off in safety or DX you can feel.
-
Object.keysreturnsstring[], not(keyof T)[]. This trips everyone. JS objects can have extra keys, so the standard library plays it safe. Cast only if you are sure:Object.keys(obj) as (keyof T)[]. -
readonlyis shallow.readonly Pizza[]stops you frompushing, but the items inside are still mutable. UseDeepReadonly<T>(or theReadonly<T>mapped type recursively) when you need it. -
Do not store types in classes if a
typeworks. Classes carry runtime cost. Types are free. -
Use
NonNullable,Awaited,ReturnType, andParametersto derive instead of duplicate. Single source of truth. -
Keep types close to the code they describe. Co locating
pizza.tsandpizza.types.ts(or just one file) beats a globaltypes/folder for everyone's sanity.
A peek under the hood
What really happens when you build a TS project:
- The compiler (
tsc) reads yourtsconfig.jsonand walks every.ts/.tsxfile in scope. - It builds a graph of types, doing inference where you did not annotate, then checks every assignment, call, and return.
- If anything fails, it prints errors. Nothing is emitted by default in that case (unless
noEmitOnErroris off). - If checks pass, it emits plain
.jsfiles (and optionally.d.tsfiles for libraries). - At runtime, your code is just JavaScript. No types, no overhead, no surprises.
In modern projects, the build is often handled by a fast bundler (Vite, esbuild, SWC, Bun) that strips types blindly without type checking, while a separate tsc --noEmit runs in CI and your editor for the actual checking. Two jobs, two tools, both happy.
Tiny tips that will save you later
- Lean on inference. Do not annotate variables the compiler already knows.
- Annotate function signatures. Especially exported ones. They are your contract with the world.
- Validate at the boundary. Schema in, typed everywhere after.
-
Use
as constfor static lookup tables. Usesatisfiesfor typed but specific configs. - Brand IDs and units. A bug between user ids and pizza ids is one keystroke away without it.
-
Crank up
strictplusnoUncheckedIndexedAccessplusexactOptionalPropertyTypeson day one. Adding them later is painful. -
Run
tsc --noEmitin CI. Editor squiggles are not enough. - Read other people's types. The TS standard library, the React types, and Zod are masterclasses. Click through to definitions in your editor whenever you are curious.
- Fancy types are a tool, not a flex. If a simple type works, use it.
Wrapping up
So that is the whole story. We were tired of JavaScript's quiet bugs. We invented a label maker that lives at the layer above the code. We wrote shapes with type and interface. We combined them with unions and intersections, and used discriminated unions for state. We made them reusable with generics, derived new ones with mapped and conditional types, and shipped a small standard library of helpers like Partial, Pick, and Awaited.
We added modern moves like as const, satisfies, and branded types to be precise without losing flexibility. We turned on the strictest flags so the compiler catches the most bugs. We validated at the edges with Zod, narrowed with type guards, and trusted the types only past that boundary.
Once that map is in your head, every TypeScript codebase starts to feel familiar. You stop fighting red squiggles and start using them as a free pair programmer who reads every line you write and quietly catches the future you about to make a mistake.
Happy typing, and put a slice aside for me.
Top comments (0)