TypeScripteasy25 min read

TypeScript, Explained Like You're Five (But In Depth)

What TypeScript actually is, why so many teams switched to it, and every core concept explained in plain English with runnable code you can edit. From types and inference to generics and utility types.

Published · by Frontend Masters India

You already write JavaScript. Things mostly work. Then one Friday a bug ships because somewhere a function expected a number and got the string "3", and now a total reads "33" instead of 6. TypeScript exists to catch that mistake before it ever runs. This article walks through the whole thing from zero, in plain language, with code you can edit and run.

What TypeScript is, in one breath

TypeScript is JavaScript with labels on your data. You tell the language "this thing is a number, that thing is text," and a checker reads your code and warns you the moment something does not line up. Then it deletes all the labels and hands the browser plain JavaScript, because browsers do not understand TypeScript at all.

That last part trips people up, so let me draw it.

you write              checker reads it           browser runs
  ───────────             ────────────────          ─────────────
  greeting.ts   ───▶   "is everything labeled    ───▶   greeting.js
  (has types)           correctly? yes/no"              (no types left)

The types are a conversation between you and the checker. They never reach the browser. Nothing about how fast your code runs changes. You are buying one thing only: the checker telling you about mistakes early.

The problem, shown not told

Here is JavaScript doing exactly what you told it, which is the problem.

function addTax(price) {
  return price + price * 0.18;
}

addTax(100);     // 118  ✅
addTax("100");   // "10010018"  💀  the + glued strings together

JavaScript never complained. It happily mashed text together and gave you garbage. You find out in production. TypeScript flips this: you say price is a number, and the second call gets flagged red in your editor before you save.

Now run the JavaScript version yourself and watch it misbehave. Change addTax("100") to addTax(100) and see the number come back.

The playground above runs your code like a browser would, so it shows you the messy result. The job of types is to stop you before you get here.

Your first type

You attach a type with a colon. Read : number out loud as "is a number."

let age: number = 30;
let name: string = "Asha";
let isAdmin: boolean = false;

Now if you try to put the wrong kind of value in, the checker stops you:

let age: number = 30;
age = "thirty"; // ❌ Type 'string' is not assignable to type 'number'

That red error is the entire pitch. The variable age promised to hold numbers, and you tried to hand it a word, so the checker refuses.

The basic building blocks

These are the everyday types. You will use the first three constantly.

| Type | What it holds | Example | |------|---------------|---------| | string | text | "hello", `hi ${name}` | | number | any number, including decimals | 42, 3.14, -7 | | boolean | true or false, nothing else | true, false | | null | "intentionally empty" | null | | undefined | "not set yet" | undefined |

JavaScript has no separate integer and float, so neither does TypeScript. A number covers both.

You barely have to write types: inference

Here is the part people do not expect. You usually do not write the type at all. TypeScript watches what you assign and figures it out.

let city = "Pune"; // TypeScript already knows this is a string
city = 42;          // ❌ same error as before, you never typed `: string`

This is called inference. The checker infers the type from the value. So in real code you write far fewer annotations than you would guess. You mostly add them in one place: function inputs, because the function cannot see who will call it later.

Try it. Hover is not available here, but change city to a number on the next line and notice the runtime does not care, while a real editor would underline it.

Functions: the place types pay off most

A function is a promise: give me these inputs, I will give you that output. Types make the promise explicit.

function greet(name: string): string {
  return "Hi " + name;
}

Read it left to right: name is a string, and the : string after the parentheses says the function hands back a string. If you forget the return, or return a number by mistake, the checker catches it because you signed a contract that says "string out."

Run a real one and edit the inputs:

Optional and default parameters

A ? after a parameter name means "you can skip this one."

function greet(name: string, title?: string): string {
  return title ? `${title} ${name}` : name;
}

greet("Asha");          // fine, title was optional
greet("Asha", "Dr.");   // also fine

Inside the function, title is either a string or undefined, so TypeScript makes you handle the "they skipped it" case. That nudge is the whole point: it remembers the edge case you would forget.

Functions that return nothing: void

Some functions just do a thing and hand nothing back. Their return type is void.

function logError(message: string): void {
  console.log("ERROR:", message);
  // no return value
}

void is your way of saying "do not expect anything useful back from me."

Describing objects

Most real data is an object, a bag of named fields. You describe its shape and the checker holds every part to it.

let user: { name: string; age: number } = {
  name: "Asha",
  age: 30,
};

user.age = "old"; // ❌ age is a number, not text
user.emial = "x"; // ❌ typo: there is no 'emial' field

Notice the second error. TypeScript caught a typo in a property name, which is one of the most common and most annoying JavaScript bugs. The shape you declared has no emial, so the checker rejects it.

Naming a shape: type aliases

Writing that shape inline every time is painful. Give it a name with type.

type User = {
  name: string;
  age: number;
  email?: string; // optional, may be missing
};

function sendWelcome(user: User): void {
  console.log("Welcome " + user.name);
}

Now User is reusable. Change the shape in one place and every function using it updates. The email? shows the optional marker again: a User is valid with or without an email.

Interfaces: the other way to name a shape

You will see interface used for the same job. For describing objects, type and interface are nearly interchangeable.

interface User {
  name: string;
  age: number;
}

The plain rule of thumb: use interface for object shapes, especially ones other code might extend, and use type for everything else (unions, which are next, do not work with interface). If your team already picked one, just follow that. The difference rarely matters day to day.

readonly: look but do not touch

Mark a field readonly and TypeScript blocks any attempt to change it after creation.

type Config = {
  readonly apiUrl: string;
};

const config: Config = { apiUrl: "https://api.site.com" };
config.apiUrl = "https://evil.com"; // ❌ Cannot assign to 'apiUrl'

Good for settings and IDs that should never move once set.

Arrays and tuples

An array of one kind of thing uses []:

let scores: number[] = [90, 85, 77];
let names: string[] = ["Asha", "Ravi"];

scores.push("100"); // ❌ that's a string, this array holds numbers

A tuple is a fixed-length array where each slot has its own type. Useful when position carries meaning, like a coordinate or a key-value pair.

let point: [number, number] = [10, 20];
let entry: [string, number] = ["age", 30];

Run an array example and break it on purpose:

Union types: "one of these"

This is where TypeScript starts feeling powerful. A union, written with |, says a value can be one of several types.

let id: string | number;
id = "abc123"; // ok
id = 7;        // also ok
id = true;     // ❌ not a string and not a number

Read string | number as "string OR number." You use this constantly for things like an ID that might be a database number or a slug string, or a function that accepts either.

Literal types: an even tighter union

You can use exact values, not just broad types, as a union. This is handy for fixed sets of options.

type Direction = "up" | "down" | "left" | "right";

function move(dir: Direction): void {
  console.log("Moving " + dir);
}

move("up");      // ok
move("diagonal"); // ❌ not one of the four allowed strings

Now move only accepts the four real directions. Typos and invalid options are impossible. Your editor even autocompletes the choices.

Narrowing: teaching the checker what you just proved

When a value is a union, you cannot use it freely, because a string | number does not have number-only methods. You first prove which one it is, and TypeScript follows your logic. This is called narrowing.

function printId(id: string | number): void {
  if (typeof id === "string") {
    // inside here, TypeScript KNOWS id is a string
    console.log(id.toUpperCase());
  } else {
    // and here it must be a number
    console.log(id.toFixed(2));
  }
}

The magic is that typeof id === "string" does not just run at runtime; the checker reads it too and narrows the type inside each branch. You did the proof, so it lets you use string methods in the string branch and number methods in the other.

Run it and feed it both kinds:

The escape hatches: any, unknown, never

Three special types exist for the edges. They are worth understanding precisely because misusing them is common.

any means "turn the checker off for this value." Everything is allowed. It is an escape hatch for when you are migrating old code or genuinely do not know the shape yet. Overuse it and you have basically gone back to plain JavaScript.

let data: any = fetchSomething();
data.whatever.deeply.nested; // no errors, no safety either

unknown is the safe cousin of any. It also holds anything, but it refuses to let you do anything with it until you check what it is. Use this for truly unknown input, like a parsed JSON response.

let input: unknown = JSON.parse(raw);
input.toUpperCase();          // ❌ checker stops you, you haven't proven it's a string
if (typeof input === "string") {
  input.toUpperCase();        // ✅ now you've proven it
}

never is the type that can never happen. You rarely write it directly, but it shows up for a function that always throws or for the impossible branch of a check. Think of it as "this code path produces no value because it cannot finish."

any      ─ "trust me, skip all checks"     (powerful, dangerous)
  unknown  ─ "I don't know yet, ask me later" (powerful, safe)
  never     ─ "this can't happen"             (rare, useful in exhaustive checks)

null and undefined, handled honestly

The famous billion-dollar mistake is calling a method on something that turned out to be empty. With the strictNullChecks setting on (it is on in any sane setup), TypeScript forces you to deal with emptiness.

function firstChar(text: string | null): string {
  return text[0]; // ❌ 'text' might be null, you can't index null
}

You fix it by checking first, which narrows null away:

function firstChar(text: string | null): string {
  if (text === null) return "";
  return text[0]; // ✅ here text is definitely a string
}

This single feature removes a huge category of real-world crashes. The checker simply will not let you forget the empty case.

Generics: types with a blank to fill in

This is the concept that scares people, and it should not. A generic is a type with a placeholder, like a function parameter but for types instead of values.

Picture a function that returns whatever you give it. Without generics you would write any and lose all type information:

function identity(value: any): any {
  return value;
}

const x = identity("hello"); // x is 'any', TypeScript forgot it was a string

With a generic, you introduce a placeholder named T (any name works, T is convention) that captures the actual type and carries it through:

function identity<T>(value: T): T {
  return value;
}

const x = identity("hello"); // x is 'string'
const y = identity(42);      // y is 'number'

Read <T> as "for whatever type the caller passes, call it T." The function works for every type while still remembering exactly which one it got. That is the whole idea: reusable code that does not throw away type information.

Here is the illustration that makes it click:

identity<T>(value: T): T
            │        │     │
            │        │     └── returns the SAME type it received
            │        └──────── takes in some type
            └───────────────── "T" is a blank, filled in per call

  identity("hi")  ▶ T becomes string ▶ returns string
  identity(42)    ▶ T becomes number ▶ returns number

You have used generics already without naming them. Array<number> is just number[] written the generic way, and Promise<User> means "a promise that will eventually give you a User." The <...> part is the blank being filled.

const ids: Array<number> = [1, 2, 3];
function getUser(): Promise<User> {
  // ...
}

Enums: a named set of constants

An enum gives friendly names to a fixed group of related values. It is an alternative to the literal-union approach from earlier.

enum Status {
  Pending,
  Active,
  Cancelled,
}

let s: Status = Status.Active;

Many teams now prefer the literal union (type Status = "pending" | "active" | "cancelled") because it is simpler and disappears at compile time, while enums generate extra JavaScript. Both are valid; know that enums exist because you will meet them in older codebases.

Type assertions: "trust me, I know what this is"

Sometimes you know more than the checker does, usually with values coming from the DOM or an external library. You can override it with as.

const input = document.getElementById("email") as HTMLInputElement;
input.value = "hi"; // now allowed, because you asserted the element type

Use this sparingly. You are telling the checker to stop arguing, which means if you are wrong, it cannot save you. It is a promise you make, not a check the compiler performs.

Utility types: ready-made transformations

TypeScript ships helpers that take a type and produce a related one, so you do not redefine shapes by hand. A few you will reach for often:

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

// Partial<T> ─ every field becomes optional. Great for updates.
type UserUpdate = Partial<User>;
// { name?: string; age?: number; email?: string }

// Pick<T, keys> ─ keep only some fields.
type UserPreview = Pick<User, "name" | "email">;

// Omit<T, keys> ─ everything except some fields.
type UserWithoutEmail = Omit<User, "email">;

// Readonly<T> ─ freeze every field.
type FrozenUser = Readonly<User>;

These save real effort. When your updateUser function should accept any subset of fields, Partial<User> expresses that in one word instead of rewriting the shape with question marks everywhere.

How the labels actually disappear

Back to where we started. You run a compiler (tsc, the TypeScript compiler) or a bundler that strips types. It reads your .ts files, checks everything, reports errors, and emits plain .js.

app.ts                              app.js
  ─────────                           ─────────
  function greet(n: string) {   ──▶   function greet(n) {
    return "Hi " + n;                   return "Hi " + n;
  }                                   }

The settings for all of this live in tsconfig.json. The one setting worth knowing on day one is "strict": true, which switches on the strong checks (including the null handling above). Turn it on and leave it on; it is where most of the value comes from.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2020"
  }
}

Why teams actually switch

Strip away the hype and there are a few honest reasons.

You catch a class of bugs at the moment you write them instead of in production: typos in property names, wrong argument types, forgotten null checks, functions called with too few arguments. Your editor gets much smarter, because it now knows the shape of everything, so autocomplete and "rename this everywhere" just work. And the types act as documentation that cannot go stale, since a wrong type is an error, unlike a comment that quietly lies.

The honest cost: there is a setup step, the compiler sometimes complains about code you know is fine, and the fancy type features have a real learning curve. For a throwaway script, plain JavaScript is faster. For anything you or a team will maintain past next week, most people find the trade worth it.

A tiny mental model to keep

If you remember nothing else, keep these three sentences:

  • Types are notes to a checker, and they vanish before the browser sees your code.
  • You write the fewest annotations on function inputs; inference handles most of the rest.
  • When a value could be several things, prove which one it is, and the checker follows along.

Everything else, generics and utility types and the rest, is built on those. Open the playgrounds above, break the examples on purpose, and watch what the runtime does versus what a type would have caught. That gap, between "crashes later" and "underlined now," is the entire reason TypeScript exists.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

Keep reading