TypeScript: Zero to Hero
Master TypeScript from scratch — types vs interfaces, union & intersection types, generics, utility types, type narrowing, discriminated unions, and TypeScript patterns used in production React and Node.js codebases.
Why TypeScript?
TypeScript is JavaScript with types. It catches bugs at compile time that JavaScript only catches at runtime (or never).
// 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
npm install -D typescript @types/node
npx tsc --init # creates tsconfig.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
// 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 stringType vs Interface
Both define object shapes. In practice:
// 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:
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
// 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:
// 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 | undefinedGeneric constraints
// 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 .lengthGeneric interfaces and classes
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:
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>; // stringType Narrowing
TypeScript narrows types inside conditional blocks:
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:
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:
// ❌ 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
// 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
// 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
// ❌ 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": trueintsconfig.json— always. No exceptionsinterfacefor object shapes,typefor 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/switchblocks; use discriminated unions for complex cases - Avoid
any— useunknownand narrow it;anyturns TypeScript into JavaScript - Avoid TypeScript enums — use string unions or
constobjects instead - TypeScript is a compile-time tool only — it adds zero runtime overhead
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.