TypeScript: Zero to Hero · Lesson 1 of 3

TypeScript: Zero to Hero

Why TypeScript?

TypeScript is JavaScript with types. It catches bugs at compile time that JavaScript only catches at runtime (or never).

TypeScript
// JavaScript — no error until runtime
function add(a, b) { return a + b; }
add("5", 3);   // returns "53" — surprise!

// TypeScript — error at compile time
function add(a: number, b: number): number { return a + b; }
add("5", 3);   // TS Error: Argument of type 'string' is not assignable to parameter of type 'number'

TypeScript doesn't change how JavaScript runs — it compiles to plain JavaScript. It's purely a development-time tool that eliminates an entire class of bugs.


Setup

Bash
npm install -D typescript @types/node
npx tsc --init          # creates tsconfig.json
JSON
// tsconfig.json — recommended settings
{
  "compilerOptions": {
    "target":           "ES2022",
    "lib":              ["ES2022", "DOM"],
    "module":           "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir":           "./dist",
    "strict":           true,          // enables all strict checks — ALWAYS use this
    "noUncheckedIndexedAccess": true,  // arrays return T | undefined
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck":     true,
    "esModuleInterop":  true
  }
}

"strict": true is the most important setting — it enables noImplicitAny, strictNullChecks, and more. Never build TypeScript without it.


Basic Types

TypeScript
// Primitives
let name:    string  = "Alice";
let age:     number  = 30;
let active:  boolean = true;
let nothing: null    = null;
let missing: undefined = undefined;

// Arrays
let names:   string[]       = ["Alice", "Bob"];
let numbers: Array<number>  = [1, 2, 3];      // same as number[]

// Tuple — fixed-length array with known types
let point:   [number, number] = [10, 20];
let person:  [string, number] = ["Alice", 30];

// any — opt out of type checking (avoid this)
let anything: any = "hello";
anything = 42;   // no error — but you lose all type safety

// unknown — safer than any — must narrow before use
let value: unknown = fetchFromApi();
if (typeof value === "string")
    console.log(value.toUpperCase());   // OK — narrowed to string

Type vs Interface

Both define object shapes. In practice:

TypeScript
// Interface — for object shapes and class contracts
interface User {
    id:      number;
    name:    string;
    email:   string;
    role?:   "admin" | "user";   // optional property
}

// Type alias — for unions, intersections, primitives, tuples
type UserId    = number;
type Status    = "active" | "inactive" | "banned";
type Pair<T>   = [T, T];
type ApiResult = User | { error: string };

Rule of thumb: use interface for object shapes; use type for everything else (unions, intersections, aliases).

Interfaces can be extended and merged — useful for library authors:

TypeScript
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }

// Declaration merging (only interfaces can do this)
interface Window { myCustomProperty: string; }

Union and Intersection Types

TypeScript
// Union — one OR the other
type StringOrNumber = string | number;
let id: StringOrNumber = "abc";
id = 123;   // also valid

// Intersection — combines two types into one
type Named    = { name: string };
type Aged     = { age: number };
type Person   = Named & Aged;   // has both name AND age

const alice: Person = { name: "Alice", age: 30 };

// Practical union: different shapes of a result
type ApiResponse<T> =
    | { success: true;  data: T }
    | { success: false; error: string };

function handleResponse(res: ApiResponse<User>) {
    if (res.success) {
        console.log(res.data.name);    // TypeScript knows data exists here
    } else {
        console.log(res.error);        // TypeScript knows error exists here
    }
}

Generics

Generics let you write reusable code that works with any type while preserving type safety:

TypeScript
// Without generics — loses type information
function identity(value: any): any { return value; }
const result = identity("hello");   // result is 'any' — useless

// With generics — type is preserved
function identity<T>(value: T): T { return value; }
const result = identity("hello");   // result is 'string' ✅
const num    = identity(42);        // result is 'number' ✅

// Generic function: get first element of any array
function first<T>(arr: T[]): T | undefined {
    return arr[0];
}
first([1, 2, 3]);      // returns number | undefined
first(["a", "b"]);     // returns string | undefined

Generic constraints

TypeScript
// Constrain T to types that have a .length property
function longest<T extends { length: number }>(a: T, b: T): T {
    return a.length >= b.length ? a : b;
}

longest("abc", "de");         // ✅ strings have .length
longest([1, 2], [3, 4, 5]);   // ✅ arrays have .length
longest(10, 20);              // ❌ Error: numbers don't have .length

Generic interfaces and classes

TypeScript
interface Repository<T> {
    getById(id: number): Promise<T | null>;
    getAll(): Promise<T[]>;
    save(entity: T): Promise<T>;
    delete(id: number): Promise<void>;
}

class UserRepository implements Repository<User> {
    async getById(id: number): Promise<User | null> { /* ... */ }
    async getAll(): Promise<User[]>                 { /* ... */ }
    async save(user: User): Promise<User>           { /* ... */ }
    async delete(id: number): Promise<void>         { /* ... */ }
}

Utility Types

TypeScript ships built-in utility types that transform existing types:

TypeScript
interface User {
    id:       number;
    name:     string;
    email:    string;
    password: string;
    role:     "admin" | "user";
}

// Partial — all properties become optional
type UpdateUser = Partial<User>;
// { id?: number; name?: string; email?: string; ... }

// Required — all properties become required
type StrictUser = Required<User>;

// Pick — select specific properties
type PublicUser = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

// Omit — exclude specific properties
type SafeUser = Omit<User, "password">;
// { id: number; name: string; email: string; role: ... }

// Record — create a map type
type RolePermissions = Record<"admin" | "user", string[]>;
// { admin: string[]; user: string[] }

// Readonly — all properties are read-only
type ImmutableUser = Readonly<User>;

// ReturnType — extract the return type of a function
function getUser(): User { /* ... */ }
type UserType = ReturnType<typeof getUser>;   // User

// Parameters — extract the parameter types of a function
type GetUserParams = Parameters<typeof getUser>;   // []

// NonNullable — remove null and undefined
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>;  // string

Type Narrowing

TypeScript narrows types inside conditional blocks:

TypeScript
function process(value: string | number | null) {
    // Typeof guard
    if (typeof value === "string") {
        console.log(value.toUpperCase());   // value is string here
    } else if (typeof value === "number") {
        console.log(value.toFixed(2));      // value is number here
    } else {
        console.log("null value");          // value is null here
    }
}

// Instanceof guard
function formatDate(input: Date | string) {
    if (input instanceof Date)
        return input.toISOString();
    return new Date(input).toISOString();
}

// Truthiness narrowing
function greet(name: string | null) {
    if (name)
        return `Hello, ${name}`;   // name is string here
    return "Hello, stranger";
}

// 'in' operator guard
interface Cat { meow(): void; }
interface Dog { bark(): void; }

function makeSound(animal: Cat | Dog) {
    if ("meow" in animal)
        animal.meow();    // TypeScript knows it's a Cat
    else
        animal.bark();    // TypeScript knows it's a Dog
}

Discriminated Unions

The most powerful narrowing pattern — add a type or kind field to distinguish union members:

TypeScript
type Shape =
    | { kind: "circle";    radius: number }
    | { kind: "rectangle"; width: number; height: number }
    | { kind: "triangle";  base: number; height: number };

function area(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "rectangle":
            return shape.width * shape.height;
        case "triangle":
            return 0.5 * shape.base * shape.height;
    }
}

// TypeScript exhaustiveness check — if you add a new shape and forget to handle it,
// you get a compile error:
function assertNever(x: never): never {
    throw new Error(`Unhandled case: ${x}`);
}

Enums vs Const Objects

Avoid TypeScript enums — they generate runtime code and have surprising behaviour. Use const objects or string unions instead:

TypeScript
// ❌ Enum — generates runtime JavaScript, numeric by default
enum Direction { Up, Down, Left, Right }
Direction.Up   // 0, not "Up"

// ✅ String union — no runtime overhead, autocomplete works
type Direction = "Up" | "Down" | "Left" | "Right";

// ✅ Const object — if you need both the key and value
const Direction = {
    Up:    "Up",
    Down:  "Down",
    Left:  "Left",
    Right: "Right",
} as const;

type Direction = typeof Direction[keyof typeof Direction];
// type Direction = "Up" | "Down" | "Left" | "Right"

TypeScript with React

TSX
// Component props
interface ButtonProps {
    label:      string;
    onClick:    () => void;
    variant?:   "primary" | "secondary" | "danger";
    disabled?:  boolean;
    children?:  React.ReactNode;
}

function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
    return (
        <button
            onClick={onClick}
            disabled={disabled}
            className={`btn btn-${variant}`}>
            {label}
        </button>
    );
}

// useState with explicit type
const [user, setUser] = useState<User | null>(null);

// useRef
const inputRef = useRef<HTMLInputElement>(null);

// Event types
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
}

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    console.log(e.target.value);
}

// Generic components
function List<T extends { id: number }>({
    items,
    renderItem,
}: {
    items:      T[];
    renderItem: (item: T) => React.ReactNode;
}) {
    return <ul>{items.map(item => <li key={item.id}>{renderItem(item)}</li>)}</ul>;
}

// Usage
<List items={users} renderItem={user => <span>{user.name}</span>} />

Template Literal Types

TypeScript
// Combine string types programmatically
type EventName = "click" | "focus" | "blur";
type Handler   = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

type HttpMethod = "get" | "post" | "put" | "delete";
type Endpoint   = `/api/${string}`;
type Route      = `${Uppercase<HttpMethod>} ${Endpoint}`;
// "GET /api/..." | "POST /api/..." | etc.

// Strongly typed CSS properties
type CSSProperty = `padding${"" | "Top" | "Right" | "Bottom" | "Left"}`;
// "padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft"

Common TypeScript Mistakes

TypeScript
// ❌ Using 'any' — defeats the purpose
function process(data: any) { return data.name; }

// ✅ Use 'unknown' and narrow it
function process(data: unknown) {
    if (typeof data === "object" && data !== null && "name" in data)
        return (data as { name: string }).name;
    throw new Error("Invalid data");
}

// ❌ Non-null assertion without reason
const element = document.getElementById("app")!;  // what if it's null?

// ✅ Check first
const element = document.getElementById("app");
if (!element) throw new Error("App element not found");

// ❌ Type assertion to bypass errors
const user = someValue as User;   // you're lying to TypeScript

// ✅ Validate the shape at runtime
function isUser(value: unknown): value is User {
    return (
        typeof value === "object" && value !== null &&
        "id" in value && "name" in value && "email" in value
    );
}

Key Takeaways

  • "strict": true in tsconfig.json — always. No exceptions
  • interface for object shapes, type for unions, intersections, and aliases
  • Generics are the key to reusable, type-safe code — learn them early
  • Utility types (Partial, Pick, Omit, Record, ReturnType) eliminate duplication
  • Type narrowing — TypeScript gets smarter inside if/switch blocks; use discriminated unions for complex cases
  • Avoid any — use unknown and narrow it; any turns TypeScript into JavaScript
  • Avoid TypeScript enums — use string unions or const objects instead
  • TypeScript is a compile-time tool only — it adds zero runtime overhead