DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning TypeScript As If You Built It Yourself

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;
}
Enter fullscreen mode Exit fullscreen mode

After compilation, the file becomes:

function add(a, b) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

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, and namespace. The modern advice is to avoid enum and 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
Enter fullscreen mode Exit fullscreen mode

Two escape hatches matter a lot:

  • any turns off the type checker for that value. Treat it like a swear word. The whole point of TS is to know what you have, and any says "I do not know and I do not want to". Use only as a last resort, and isolate it.
  • unknown also 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

Two ways to write this exist, and they confuse newcomers. There are type aliases and interface declarations:

interface Pizza {
  id: string;
  name: string;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Practical advice in 2026:

  • Use type by default. It can do everything interface can, plus unions, intersections, mapped types, and conditional types.
  • Use interface when 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
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>> { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Template literal types

Strings as types, with interpolation.

type EventName<T extends string> = `on${Capitalize<T>}`;

type X = EventName<"click">; // "onClick"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

The two flags every senior should know about beyond strict:

  • noUncheckedIndexedAccess turns array[i] and record[key] into T | undefined. Almost all "cannot read property of undefined" production bugs disappear when this is on, because the compiler now forces you to check.
  • exactOptionalPropertyTypes stops { 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";
Enter fullscreen mode Exit fullscreen mode

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 {};
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 with isolatedModules. type Status = "open" | "closed" is simpler and erased.
  • Prefer unknown to any everywhere data crosses a boundary. Validate, then narrow.
  • Avoid type assertions (x as T) unless you genuinely know more than the compiler. Each as is a tiny lie. Reach for satisfies, type guards, or schema validators first.
  • Variance matters more than you think. Function parameters are contravariant, return types are covariant. If (a: Animal) => void is assignable where (a: Cat) => void is expected, that is why. Do not fight this, learn the rules.
  • Distributive conditional types kick in over naked unions. T extends X ? A : B distributes over each member of T if T is 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.keys returns string[], 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)[].
  • readonly is shallow. readonly Pizza[] stops you from pushing, but the items inside are still mutable. Use DeepReadonly<T> (or the Readonly<T> mapped type recursively) when you need it.
  • Do not store types in classes if a type works. Classes carry runtime cost. Types are free.
  • Use NonNullable, Awaited, ReturnType, and Parameters to derive instead of duplicate. Single source of truth.
  • Keep types close to the code they describe. Co locating pizza.ts and pizza.types.ts (or just one file) beats a global types/ folder for everyone's sanity.

A peek under the hood

What really happens when you build a TS project:

  1. The compiler (tsc) reads your tsconfig.json and walks every .ts/.tsx file in scope.
  2. It builds a graph of types, doing inference where you did not annotate, then checks every assignment, call, and return.
  3. If anything fails, it prints errors. Nothing is emitted by default in that case (unless noEmitOnError is off).
  4. If checks pass, it emits plain .js files (and optionally .d.ts files for libraries).
  5. 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 const for static lookup tables. Use satisfies for typed but specific configs.
  • Brand IDs and units. A bug between user ids and pizza ids is one keystroke away without it.
  • Crank up strict plus noUncheckedIndexedAccess plus exactOptionalPropertyTypes on day one. Adding them later is painful.
  • Run tsc --noEmit in 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)