Skip to content
elephantoo

Introduction to TypeScript

Lesson 33 of 34 18 min read

Types, interfaces, unions, generics and setting up tsc — JavaScript that catches bugs before you run it.


TypeScript is JavaScript with static types. You describe what kind of values your variables, parameters and objects hold, and the TypeScript checker finds mistakes — typos, missing properties, undefined values, wrong argument types — as you type, before the code ever runs. It's now the default for most professional JavaScript projects, including React apps.

Every JavaScript program is (almost) valid TypeScript, so you can adopt it gradually.

Why types?#

JavaScript
// JavaScript: this bug only shows up when the code runs
function totalPrice(items) {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
totalPrice([{ price: 100, qty: 2 }]); // NaN – "qty" should be "quantity"
TS
// TypeScript: the editor underlines the mistake immediately
type CartItem = { price: number; quantity: number };

function totalPrice(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

totalPrice([{ price: 100, qty: 2 }]);
// ❌ Object literal may only specify known properties, and 'qty' does not exist in type 'CartItem'.

Types also power editor features: accurate autocomplete, go-to-definition, safe renaming, and inline documentation.

Setting up#

Terminal
mkdir ts-demo && cd ts-demo
npm init -y
npm install -D typescript
npx tsc --init          # creates tsconfig.json with sensible, strict defaults

Write hello.ts:

TS
const user: string = "Ada";
const lessons: number = 34;
console.log(`${user} is taking ${lessons} lessons`);

There are three common ways to run it:

Terminal
npx tsc --noEmit        # type-check the project without producing files
npx tsc                 # type-check and compile .ts → .js (configure outDir in tsconfig.json)
node hello.ts           # Node 22.18+/24 strips the types and runs it directly
Output
Ada is taking 34 lessons

Node's built-in support only removes types — it doesn't check them — so keep tsc --noEmit in your workflow (or rely on your editor). In front-end projects, Vite handles .ts/.tsx files for you, and npm create vite@latest offers TypeScript templates. TypeScript 7 rewrote the compiler in Go, making type-checking roughly ten times faster than before.

Basic types#

TS
let title: string = "TypeScript";
let price: number = 499;          // integers and decimals
let published: boolean = true;
let nothing: null = null;
let notSet: undefined = undefined;
let big: bigint = 10n;

let tags: string[] = ["web", "types"];         // array of strings
let scores: Array<number> = [91, 78];          // same thing, generic syntax
let point: [number, number] = [10, 20];        // tuple: fixed length and types

Type inference

You rarely need to write types for variables — TypeScript infers them from the value:

TS
let count = 0;          // inferred as number
count = "five";         // ❌ Type 'string' is not assignable to type 'number'.

const langs = ["js", "ts"]; // string[]
const doubled = langs.map((l) => l.length * 2); // number[]

A good rule: annotate function parameters and public APIs; let inference handle the rest.

Functions#

TS
function greet(name: string, excited = false): string {
  return `Hello, ${name}${excited ? "!" : "."}`;
}

const add = (a: number, b: number) => a + b; // return type inferred as number

function log(message: string, level?: "info" | "warn"): void {
  console.log(`[${level ?? "info"}] ${message}`);
}

greet("Ada");          // ✅
greet("Ada", "yes");   // ❌ Argument of type '"yes"' is not assignable to parameter of type 'boolean | undefined'.
greet();               // ❌ Expected 1-2 arguments, but got 0.

? marks optional parameters; void means "returns nothing useful".

Object types: type and interface#

TS
interface User {
  id: number;
  name: string;
  email?: string;              // optional property
  readonly createdAt: Date;    // can't be reassigned
}

type Course = {
  slug: string;
  title: string;
  lessons: number;
};

const ada: User = { id: 1, name: "Ada", createdAt: new Date() };
ada.createdAt = new Date();  // ❌ Cannot assign to 'createdAt' because it is a read-only property.

function describe(course: Course) {
  return `${course.title} (${course.lessons} lessons)`;
}

interface and type are interchangeable for object shapes. Interfaces can be extended and merged; type can also name unions and other combinations. Pick one style per codebase — many teams use type for everything except public library APIs.

TS
interface Admin extends User {
  permissions: string[];
}

type WithTimestamps<T> = T & { updatedAt: Date }; // intersection: both sets of properties

Union types and narrowing#

A union means "one of these types":

TS
type Id = number | string;
type Status = "draft" | "published" | "archived"; // literal types – only these strings

function formatId(id: Id): string {
  if (typeof id === "number") {
    return id.toFixed(0).padStart(6, "0"); // here id is a number
  }
  return id.toUpperCase();                 // here id is a string
}

console.log(formatId(42));     // 000042
console.log(formatId("ab12")); // AB12

let status: Status = "draft";
status = "deleted"; // ❌ Type '"deleted"' is not assignable to type 'Status'.

TypeScript narrows a union inside if blocks using typeof, Array.isArray, in, instanceof and equality checks.

Discriminated unions

Give each variant a common literal property and TypeScript can tell them apart — perfect for modelling states:

TS
type RequestState =
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(state: RequestState): string {
  switch (state.status) {
    case "loading":
      return "Loading…";
    case "success":
      return state.data.join(", ");  // data only exists in this branch
    case "error":
      return `Error: ${state.message}`;
  }
}

console.log(render({ status: "success", data: ["js", "ts"] })); // js, ts

This makes impossible states (like "loading and has an error") impossible to write.

null, undefined and strict mode#

With "strict": true (the default from tsc --init), null and undefined must be handled explicitly:

TS
function findUser(id: number): User | undefined {
  return [ada].find((u) => u.id === id);
}

const found = findUser(2);
console.log(found.name);   // ❌ 'found' is possibly 'undefined'.
console.log(found?.name);  // ✅
if (found) console.log(found.name); // ✅ narrowed

This single check prevents the most common JavaScript runtime error: Cannot read properties of undefined.

any vs unknown#

TS
const data: any = JSON.parse('{"name":"Ada"}');
data.nmae.toUpperCase(); // no error from TypeScript – crashes at runtime

const safe: unknown = JSON.parse('{"name":"Ada"}');
// safe.name;            // ❌ 'safe' is of type 'unknown'.
if (typeof safe === "object" && safe !== null && "name" in safe && typeof safe.name === "string") {
  console.log(safe.name.toUpperCase()); // ✅ ADA
}

any switches the checker off and spreads silently. Use unknown for values you haven't validated yet. For real apps, a validation library such as Zod or Valibot checks API data at runtime and gives you the TypeScript type.

Generics#

Generics are type parameters — they let a function or type work with many types while keeping them connected:

TS
function first<T>(items: T[]): T | undefined {
  return items[0];
}

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

type ApiResponse<T> = {
  data: T;
  error: string | null;
};

async function getJSON<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as T; // a promise to the checker – validate in real apps
}

type Todo = { id: number; title: string; completed: boolean };
const todo = await getJSON<Todo>("https://jsonplaceholder.typicode.com/todos/1");
console.log(todo.title.toUpperCase()); // autocomplete knows `title` is a string

You use generics constantly without noticing: Array<string>, Promise<User>, Map<string, number>, React's useState<User | null>(null).

Handy utility types#

TS
interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

type ProductPreview = Pick<Product, "id" | "name">;   // only some properties
type NewProduct = Omit<Product, "id">;                // all except id
type ProductUpdate = Partial<Product>;                // every property optional
type PriceList = Record<string, number>;              // { [key: string]: number }
type FrozenProduct = Readonly<Product>;

function updateProduct(id: number, changes: ProductUpdate) {
  /* ... */
}
updateProduct(1, { price: 399 }); // ✅ only what changed

Also useful: ReturnType<typeof fn>, Parameters<typeof fn>, Awaited<T>, NonNullable<T>, and as const for literal types:

TS
const ROLES = ["student", "teacher", "admin"] as const;
type Role = (typeof ROLES)[number]; // "student" | "teacher" | "admin"

Classes in TypeScript#

TS
class Account {
  #balance = 0;

  constructor(public readonly owner: string) {} // parameter property

  deposit(amount: number): this {
    if (amount <= 0) throw new RangeError("Amount must be positive");
    this.#balance += amount;
    return this;
  }

  get balance(): number {
    return this.#balance;
  }
}

const acc = new Account("Ada").deposit(500);
console.log(acc.owner, acc.balance); // Ada 500

(Parameter properties like public readonly owner generate code, so they're rejected by the erasableSyntaxOnly setting that Node's type stripping requires. Write a normal field plus constructor assignment if you run .ts files with Node directly.)

Typing the DOM#

TypeScript knows every DOM API:

TS
const input = document.querySelector<HTMLInputElement>("#email");
if (input) {
  input.value = "ada@example.com"; // `value` exists on HTMLInputElement
}

document.querySelector("form")?.addEventListener("submit", (event: SubmitEvent) => {
  event.preventDefault();
  const form = event.currentTarget as HTMLFormElement;
  console.log(new FormData(form).get("email"));
});

querySelector returns Element | null, so TypeScript makes you handle the "not found" case.

Adding TypeScript to a JavaScript project#

  1. Install TypeScript and create a tsconfig.json with "allowJs": true.
  2. Optionally add // @ts-check to the top of .js files — TypeScript checks them using inference and JSDoc comments.
  3. Rename files from .js to .ts one at a time, fixing errors as you go.
  4. Install types for libraries that don't ship their own: npm i -D @types/node.

Common mistakes#

  • Using any to make errors go away. Use unknown and narrow, or fix the type.
  • Overusing as casts and the ! non-null assertion — they're promises to the compiler that it can't verify.
  • Assuming types validate data at runtime — API responses can still be anything. Validate external data.
  • Annotating everything. Let inference work; annotate boundaries (function parameters, exports).
  • Turning off strict — you lose the most valuable checks.

What's next#

Finally, bring everything together with performance and best practices: writing JavaScript that's fast, readable and maintainable.

Check your understanding

Quick quiz

0/3 answered
  1. 1.When does TypeScript report type errors?

  2. 2.Given function len(x: string | string[]) { ... }, how can you safely call a string-only method on x?

  3. 3.Why prefer unknown over any for data of uncertain shape (e.g. JSON.parse results)?

Finished reading?

Mark this lesson complete to track your progress.