Loading
Learn interfaces, generics, union types, and type guards to shift your mental model from JavaScript to TypeScript.
TypeScript is not JavaScript with extra syntax sprinkled on top. It is a fundamentally different way of thinking about your code. Instead of asking "what does this do at runtime?" you start asking "what shapes of data can flow through here?" This guide teaches you to think in types.
In JavaScript, you pass objects around and hope they have the right properties. In TypeScript, you describe the shape up front.
Now the compiler knows exactly what a User looks like. If you misspell user.naem, it catches that before you ever run the code. If you pass an object missing the email field, it catches that too.
Use interface for object shapes:
Use type for unions, intersections, and computed types:
Make optional fields explicit with ?:
Real data is messy. A value might be one of several types. Union types model this honestly.
This is called a discriminated union — the status field tells TypeScript which variant you are dealing with. The compiler narrows the type automatically inside each branch.
Model state machines with unions instead of separate booleans:
When a value could be one of several types, you need to narrow it before using it. TypeScript tracks these checks through your control flow.
For complex checks, write a custom type guard:
Generics let you write functions that work with any type while preserving type information.
Constrain generics when you need specific properties:
A practical example — a typed event emitter:
TypeScript includes built-in utility types that transform existing types. These prevent you from repeating yourself.
Combine them for real patterns:
The TypeScript compiler is not an obstacle. It is a pair programmer that catches bugs before they reach production. When it complains, resist the urge to silence it with any or @ts-ignore. Instead, ask: "What is the compiler trying to tell me?"
Rules for living with TypeScript:
any. Use unknown if the type is truly unknown, then narrow it.The mental shift is this: in JavaScript, types exist at runtime and you discover bugs by running the code. In TypeScript, types exist at write time and the compiler discovers bugs before you run anything. Every red squiggly line is a bug you did not ship.
interface User {
id: string;
name: string;
email: string;
role: "admin" | "instructor" | "student";
createdAt: Date;
}
function greetUser(user: User): string {
return `Hello, ${user.name}`;
}interface Lesson {
id: string;
title: string;
content: string;
durationMinutes: number;
isPublished: boolean;
tags: string[];
}type Status = "idle" | "loading" | "success" | "error";
type LessonWithAuthor = Lesson & { author: User };
type LessonKeys = keyof Lesson; // "id" | "title" | "content" | ...interface UserProfile {
id: string;
name: string;
bio?: string; // might not exist
avatarUrl?: string; // might not exist
}// An API response is either data or an error — never both
type ApiResponse<T> = { status: "success"; data: T } | { status: "error"; error: string };
function handleResponse(response: ApiResponse<User[]>): void {
if (response.status === "success") {
// TypeScript KNOWS response.data exists here
console.log(response.data.length);
} else {
// TypeScript KNOWS response.error exists here
console.error(response.error);
}
}// Bad: these booleans can be in impossible states
// isLoading: true AND isError: true? What does that mean?
interface BadState {
isLoading: boolean;
isError: boolean;
data: User[] | null;
error: string | null;
}
// Good: each state is explicit and exclusive
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };// typeof narrows primitives
function formatValue(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase(); // TS knows it's a string
}
return value.toFixed(2); // TS knows it's a number
}
// "in" narrows by property existence
interface Dog {
bark(): void;
breed: string;
}
interface Cat {
meow(): void;
color: string;
}
function speak(pet: Dog | Cat): void {
if ("bark" in pet) {
pet.bark(); // TS knows it's a Dog
} else {
pet.meow(); // TS knows it's a Cat
}
}
// instanceof narrows class instances
function handleError(error: unknown): string {
if (error instanceof Error) {
return error.message; // TS knows it's an Error
}
return String(error);
}interface ApiError {
code: number;
message: string;
}
function isApiError(value: unknown): value is ApiError {
return typeof value === "object" && value !== null && "code" in value && "message" in value;
}
// Now use it
const response: unknown = await fetchSomething();
if (isApiError(response)) {
console.log(response.code); // TypeScript is satisfied
}// Without generics: you lose type information
function first(arr: unknown[]): unknown {
return arr[0];
}
const value = first(["hello"]); // type is unknown — useless
// With generics: type flows through
function firstOf<T>(arr: T[]): T | undefined {
return arr[0];
}
const name = firstOf(["hello"]); // type is string
const count = firstOf([1, 2, 3]); // type is number// T must have an "id" property
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
// Works with any object that has an id
const user = findById(users, "abc123"); // type is User | undefined
const lesson = findById(lessons, "xyz"); // type is Lesson | undefinedinterface EventMap {
login: { userId: string };
logout: undefined;
progress: { lessonId: string; percent: number };
}
function emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
// implementation
}
emit("login", { userId: "abc" }); // OK
emit("progress", { lessonId: "1", percent: 50 }); // OK
emit("login", { wrong: true }); // Error — wrong shapeinterface User {
id: string;
name: string;
email: string;
role: string;
}
// All fields optional (for update operations)
type UserUpdate = Partial<User>;
// { id?: string; name?: string; email?: string; role?: string }
// All fields required
type RequiredUser = Required<User>;
// Only certain fields
type UserPreview = Pick<User, "id" | "name">;
// { id: string; name: string }
// Everything except certain fields
type UserPublic = Omit<User, "email">;
// { id: string; name: string; role: string }
// Build a type from keys and values
type RolePermissions = Record<string, boolean>;
// { [key: string]: boolean }// A create function that doesn't need the id (server generates it)
type CreateUserInput = Omit<User, "id">;
// An update function where everything is optional except the id
type UpdateUserInput = Pick<User, "id"> & Partial<Omit<User, "id">>;// Bad: silencing the compiler
const data = response as any;
data.whatever.you.want; // no errors, no safety
// Good: working with the compiler
if (isApiResponse(response)) {
response.data; // safe, verified
}