TypeScript for JavaScript Developers

You wrote this function last week and it worked:

function priceWithTax(price, rate) {
  return price + price * rate;
}

Today a teammate calls priceWithTax("19.99", 0.08) because the price came out of a form field as a string. No error. No crash. Just "19.9919.99..." silently flowing into an invoice. The bug ships, a customer sees a wrong total, and you spend an afternoon tracing it back.

TypeScript is the tool that turns that afternoon into a red squiggle under "19.99" while you type — before you save, before you run, before anyone sees it. It’s plain JavaScript with a layer of types on top: notes about what shape each value is supposed to be. A checker reads those notes and tells you where the shapes don’t line up.

This isn’t a full TypeScript course. It’s the on-ramp: enough of the mental model and the core features that you can read typed code, add types to your own, and understand why the whole ecosystem moved this way. As of 2026, TypeScript is the default for serious JavaScript work — new libraries ship types, editors assume them, and hiring posts list it without a second thought.

The one idea that makes everything click

Types are erased. They exist only while you’re editing and while a checker runs. They never make it into the JavaScript that actually executes. There is no Price object at runtime, no type tag riding along on your values, no performance cost. Strip every annotation out and you’re left with exactly the JavaScript you would have written by hand.

source.tsfunction tax(p: number,r: number): number {return p + p*r;}violet = type annotationstype check(shapes match?)erasesource.js (runs)function tax(p,r) {return p + p*r;}types are gone
The typed file is checked, then its type annotations are deleted (replaced by whitespace) to produce the plain JavaScript that runs. Types never reach the runtime.

Hold onto this. Almost every confusing thing about TypeScript dissolves once you remember that types are a separate world that gets deleted before execution. You cannot check a type with if at runtime — there’s nothing there to check. You cannot import a type and expect a value. The types help you and your editor; then they vanish.

Annotations: the colon

The core syntax is a colon after a name, followed by a type. That’s it for the common case.

let price: number = 19.99;
let label: string = "Total";
let paid: boolean = false;
let tags: string[] = ["sale", "new"];

You rarely need to be this explicit. TypeScript infers the type from the value, so let price = 19.99 already knows price is a number. Reassign it to "nope" and you get an error for free. The place annotations really earn their keep is function boundaries — inputs and outputs — because those are the contracts other code depends on.

function priceWithTax(price: number, rate: number): number {
  return price + price * rate;
}

priceWithTax("19.99", 0.08);
//           ~~~~~~~ Argument of type 'string' is not
//                   assignable to parameter of type 'number'.

The return annotation : number after the parameter list is optional (it’s inferred too), but writing it is a small act of self-defense: if a future edit makes the function accidentally return undefined, the annotation catches it at the definition instead of at some faraway call site.

checkout.ts — editor
1  const total = priceWithTax(
2    “19.99”, 0.08
3  );

ts(2345)  Argument of type ‘string’ is not assignable to parameter of type ‘number’.

The checker reports the mismatch at the call, in the editor, with a red underline — no need to run the program to find it.

Objects, interfaces, and type aliases

Most real data is objects, and you describe an object’s shape with either an interface or a type alias.

interface User {
  id: number;
  name: string;
  email: string;
}

function greet(user: User): string {
  return `Hi ${user.name}`;
}

A type alias does the same job with an =:

type User = {
  id: number;
  name: string;
  email: string;
};

For plain object shapes they’re interchangeable — pick one and be consistent. The differences show up at the edges:

Unions and literal types

A union type says a value is one of several types. Write it with a vertical bar.

let id: number | string;
id = 42;      // ok
id = "42a";   // ok
id = true;    // Error: boolean is not number | string

Unions get sharp when you union literal values instead of whole types. A string literal type is a string that can only be one exact value:

type Status = "idle" | "loading" | "success" | "error";

function setStatus(s: Status) { /* ... */ }

setStatus("loading");  // ok
setStatus("loded");    // Error — typo caught at compile time

Status is like an enum, but it’s pure type information: it erases completely, and the values are just plain strings at runtime. This is the modern idiom. (TypeScript also has an enum keyword, but it emits real runtime code, which is exactly the kind of thing that trips up the erase-only tools we’ll meet later — so many teams avoid it.)

Narrowing: proving which branch you’re in

A union is a value that could be several things. Before you can safely use it, you often need to prove which one it is right now. TypeScript follows your ordinary control flow and narrows the type inside each branch. This is called narrowing, and it’s where TypeScript feels less like paperwork and more like a helper reading along with you.

function format(value: number | string): string {
  if (typeof value === "number") {
    // here, TypeScript knows value is number
    return value.toFixed(2);
  }
  // here, it knows value is string
  return value.trim();
}
value: number | stringtypeof === “number”truefalsevalue: numbervalue.toFixed(2)value: stringvalue.trim()
One incoming union splits into two branches. A typeof guard eliminates a possibility on each side, so inside each branch the type is narrowed to exactly one member.

The guards that narrow are the same ones you already use in JavaScript: typeof, Array.isArray, in, instanceof, and truthiness checks (if (user)). TypeScript understands all of them. For your own richer conditions, you can write a type predicate — a function whose return type is arg is Type:

type Cat = { meow: () => void };
type Dog = { bark: () => void };

function isCat(pet: Cat | Dog): pet is Cat {
  return "meow" in pet;
}

function speak(pet: Cat | Dog) {
  if (isCat(pet)) {
    pet.meow();  // narrowed to Cat
  } else {
    pet.bark();  // narrowed to Dog
  }
}
Watch the type narrow as control flow proceeds
1/3
Variables
value="' hi '"
Called with a string. Statically, value's type is number | string.

Structural typing: shape, not name

Here’s the feature that surprises people coming from Java or C#. TypeScript matches types by their shape, not by their name. If a value has all the members a type requires, it is that type — no explicit implements, no declared relationship.

interface Point {
  x: number;
  y: number;
}

function distance(p: Point): number {
  return Math.hypot(p.x, p.y);
}

// never mentions Point, but has the right shape:
const marker = { x: 3, y: 4, label: "start" };

distance(marker); // ok — extra 'label' is fine

marker was never declared as a Point. It doesn’t matter. It has an x: number and a y: number, so it satisfies Point. Extra properties are allowed when you pass an existing variable. This is often called duck typing checked at compile time: if it walks and quacks like a Point, it’s a Point.

required: Pointx: numbery: numbershape coversthe requirementvalue: markerx: numbery: numberlabel: string
Structural typing compares the members two types have, not their names. Any value whose shape covers the required members is accepted.

Generics: types with a parameter

You already understand generics intuitively. An array isn’t just “array” — it’s “array of something”. string[] and number[] share behavior but carry different element types. Generics let you write that “of something” for your own functions and types, using a type variable in angle brackets.

Without generics you’d either duplicate a function per type or fall back to a type that loses information:

// loses the type — returns any, so mistakes slip through later
function firstBad(arr: any[]) {
  return arr[0];
}

// generic: T is inferred from the argument, and flows to the result
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const n = first([1, 2, 3]);        // n: number | undefined
const s = first(["a", "b"]);       // s: string | undefined

Read <T> as “for some type T, decided at the call”. You don’t pass T yourself; TypeScript infers it from the argument and threads it through to the return type, so first of a number[] gives you back a number. One implementation, exact types at every call.

first<T>(arr: T[]): T | undefinedfirst([1, 2, 3])T = number→ number | undefinedfirst([“a”, “b”])T = string→ string | undefined
A generic function is a template. The same code specializes to a concrete type each time it's called, based on the argument you pass.

You’ll consume generics constantly before you ever write one: Array<T>, Promise<T>, Map<K, V>, Set<T>, and React’s useState<T> are all generic. When you see Promise<User>, read it as “a promise that resolves to a User”. That’s the whole intuition; the advanced machinery (constraints with extends, defaults, conditional types) is a later chapter.

any vs unknown: the two escape hatches

Sometimes you genuinely don’t know a type — data from JSON.parse, a value from an untyped library. TypeScript gives you two exits, and choosing right matters.

any switches the type checker off for that value. It accepts anything and lets you do anything to it, with no warnings. It’s contagious: one any tends to spread through everything it touches, silently disabling checks far from where it started.

unknown is the safe counterpart. A value is unknown when it could be anything — but TypeScript won’t let you use it until you narrow it down to something specific.

function handle(raw: unknown) {
  raw.toUpperCase();
  // ~~~ Error: 'raw' is of type 'unknown'

  if (typeof raw === "string") {
    raw.toUpperCase(); // ok — narrowed to string
  }
}
any
Checker off. Any operation compiles. Bugs pass straight through and surface at runtime.
raw.toUpperCase() ✓ compiles
raw.foo.bar() ✓ compiles
💥 crashes at runtime
unknown
Holds any value, but blocks use until you prove the type with a guard.
raw.toUpperCase() ✗ blocked
if (typeof raw === “string”)
  raw.toUpperCase() ✓ safe
any turns checking off and accepts every operation. unknown accepts every value but forces you to narrow before you use it. Reach for unknown.

Rule of thumb: when a type is uncertain, start with unknown and narrow. Keep any for genuine emergencies and mark them so you can find them later.

Optional and readonly

Two small modifiers you’ll use everywhere. A ? after a property name makes it optional — the object might not have it, and its type becomes T | undefined. A readonly prefix forbids reassigning the property after creation (a compile-time promise; it doesn’t freeze the object at runtime).

interface Config {
  url: string;
  timeout?: number;        // may be missing
  readonly apiKey: string; // can't be reassigned after init
}

function load(cfg: Config) {
  const t = cfg.timeout ?? 3000; // handle the missing case
  cfg.apiKey = "x";
  //  ~~~~~~ Error: Cannot assign to 'apiKey', it is read-only.
}

Optional properties nudge you to handle the “not there” case explicitly (here with ??, the nullish coalescing operator), which is precisely the kind of gap that becomes a Cannot read properties of undefined at 2am.

Where the types actually run

Types do their work in two places, and neither is your production runtime.

  1. Your editor, continuously, through a language server that type-checks as you type. This is the always-on intelligence.
  2. A checker you run on purpose — historically tsc, the TypeScript compiler — in a pre-commit hook or CI, to fail the build when types don’t line up.

A minimal tsconfig.json sets the ground rules for both:

{
  "compilerOptions": {
    "strict": true,
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "noUncheckedIndexedAccess": true
  }
}

strict: true is the one setting that matters most — it’s a bundle of checks (including no implicit any and strict null handling) that turns TypeScript from a suggestion into a real safety net. Turn it on from day one; retrofitting it later is painful. noUncheckedIndexedAccess adds undefined to indexed access like arr[i], reflecting the truth that the slot might be empty.

Running TypeScript without a build step

For years, “using TypeScript” meant a build pipeline: write .ts, compile to .js, run the .js. In 2026 that’s often unnecessary, because the runtime strips the types for you.

Node.js (24 LTS and newer) runs .ts files directly. It does type stripping: it deletes the type annotations — literally replacing them with whitespace so line and column numbers stay put — and hands plain JavaScript to V8. This became the default in Node 23.6 / 22.18 and reached stable in Node 24.12 / 25.2.

node app.ts   # just works, no compile step
app.tstsc (compile+ type check)node app.jsclassic: checkedstrip types(no check!)runs directlynative: fast, unchecked
Two paths to a running program. The classic path compiles ahead of time with type checking. The native path strips types on the fly and skips the check — fast to start, but you still need a separate checker for safety.

Two catches worth burning into memory:

  • Stripping is not checking. Node deletes the types and runs; it never tells you they were wrong. Type errors still need a real checker (tsc --noEmit) in your editor and CI. Native execution replaces the build step, not the type check.
  • Only erasable syntax works. Because Node merely deletes types, features that emit runtime code — enum, namespace with runtime members, parameter properties, some decorators — aren’t supported natively. Set "erasableSyntaxOnly": true in your tsconfig and TypeScript will flag those constructs for you, so you never write something the runtime can’t strip. Node also ignores tsconfig.json entirely, so paths aliases and downleveling don’t apply — reach for a tool like tsx if you need them.

The no-build alternative: JSDoc types on plain JS

You can get most of TypeScript’s checking without writing any TypeScript at all. Write ordinary .js files and describe types in JSDoc comments. The same checker understands them.

/**
 * @param {number} price
 * @param {number} rate
 * @returns {number}
 */
function priceWithTax(price, rate) {
  return price + price * rate;
}

priceWithTax("19.99", 0.08); // still an error, in a .js file

Add // @ts-check at the top of a file (or "checkJs": true in tsconfig) and TypeScript checks your JavaScript against those comments — same editor intelligence, same errors, zero build step and zero new file extensions. You can even define shapes with @typedef and import types with @import.

Where to start

You don’t adopt all of this at once. A realistic on-ramp:

  1. Turn on strict in a tsconfig.json and let your editor start flagging things.
  2. Annotate function boundaries — parameters and returns. Let inference handle the insides.
  3. Model your data with interface and type, using unions of string literals for fixed sets of options.
  4. Replace any with unknown plus narrowing wherever you’re tempted to cheat.
  5. Run tsc --noEmit in CI so a type error fails the build, not the customer’s checkout.

The mental shift is small: you’re writing the same JavaScript, plus a running commentary about what your values are supposed to be. A machine reads that commentary and keeps you honest — then throws it away before anything runs.

Summary

  • Types are compile-time only. They’re checked, then erased to plain JavaScript. No runtime cost, no runtime type info, no behavior change.
  • The biggest win is editor intelligence — autocomplete, safe refactors, inline docs — with early error-catching as a bonus.
  • Annotate boundaries with :, and lean on inference everywhere else. Return annotations are cheap insurance.
  • interface and type both describe object shapes; type also does unions, tuples, and more. Use string-literal unions instead of enum.
  • Narrowing uses ordinary typeof / in / instanceof / truthiness checks (and custom x is T predicates) to prove which member of a union you have.
  • Structural typing matches by shape, not by name — if the members line up, the value fits.
  • Prefer unknown over any. any disables checking and spreads; unknown forces you to narrow first.
  • ? makes a property optional; readonly forbids reassignment (a compile-time promise, not a runtime freeze).
  • Node 24+ runs .ts directly by stripping types — but stripping is not checking, and only erasable syntax works. Keep a real checker (tsc --noEmit) in CI.
  • JSDoc + // @ts-check gives you TypeScript-grade checking on plain .js with no build step — a solid path for incremental adoption.