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).
// 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