Episode 1 — Fundamentals / 1.25 — TypeScript Essentials

1.25.b — Thinking in TypeScript

In one sentence: Thinking in TypeScript means adopting a types-first mindset — defining the shape of your data before writing logic, letting the type system guide your design, and treating compiler errors as a helpful pair programmer rather than an obstacle.

Navigation: ← 1.25 Overview · 1.25.c — Installing and Setting Up →


1. The types-first mindset

In plain JavaScript, you typically think about logic first: "I need a function that fetches users and filters them." In TypeScript, you flip that order: define the data shapes first, then write the logic.

// Step 1: Define the data shapes
interface User {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

// Step 2: Define function signatures (contracts)
function fetchUsers(): Promise<ApiResponse<User[]>> { /* ... */ }
function filterActive(users: User[]): User[] { /* ... */ }

// Step 3: Implement the logic — the compiler guides you
function filterActive(users: User[]): User[] {
  return users.filter(user => user.isActive);
  // Autocomplete shows: id, name, email, isActive
  // Typo like user.isActve → immediate red squiggle
}

Why this matters: By the time you write the implementation, you already know:

  • What data goes in
  • What data comes out
  • What shape each piece of data has

2. Designing with types: programs as data transformations

Think of your entire application as a pipeline of data transformations:

Raw Input → Validated Input → Processed Data → Output

FormData → ValidatedOrder → PricedOrder → OrderConfirmation

Each step has a type that describes its shape:

// Raw form input — everything is strings
interface OrderFormData {
  productId: string;
  quantity: string;
  couponCode: string;
}

// After validation — types are narrowed
interface ValidatedOrder {
  productId: number;
  quantity: number;
  couponCode: string | null;
}

// After pricing logic
interface PricedOrder extends ValidatedOrder {
  unitPrice: number;
  discount: number;
  total: number;
}

// Final output
interface OrderConfirmation {
  orderId: string;
  total: number;
  estimatedDelivery: Date;
}

// Now the pipeline is self-documenting:
function validateOrder(form: OrderFormData): ValidatedOrder { /* ... */ }
function priceOrder(order: ValidatedOrder): PricedOrder { /* ... */ }
function confirmOrder(order: PricedOrder): OrderConfirmation { /* ... */ }

Reading these signatures alone tells you exactly how data flows through the system.


3. Type-driven development workflow

A practical workflow for building features with TypeScript:

1. Define types/interfaces for the data
2. Write function signatures (inputs → outputs)
3. Implement — let the compiler guide you
4. Refactor — the compiler catches breakage

Example — building a todo feature:

// Step 1: Define the types
type Priority = "low" | "medium" | "high";
type Status = "pending" | "in-progress" | "done";

interface Todo {
  id: string;
  title: string;
  priority: Priority;
  status: Status;
  createdAt: Date;
}

// Step 2: Write function signatures
function createTodo(title: string, priority: Priority): Todo { /* ... */ }
function updateStatus(todo: Todo, status: Status): Todo { /* ... */ }
function filterByPriority(todos: Todo[], priority: Priority): Todo[] { /* ... */ }
function sortByDate(todos: Todo[]): Todo[] { /* ... */ }

// Step 3: Implement — the types prevent mistakes
function createTodo(title: string, priority: Priority): Todo {
  return {
    id: crypto.randomUUID(),
    title,
    priority,
    status: "pending",  // Autocomplete shows: "pending" | "in-progress" | "done"
    createdAt: new Date(),
  };
}

// Step 4: If you refactor (e.g., rename 'priority' to 'urgency'),
// the compiler flags every place you need to update.

4. Reading error messages

TypeScript errors look intimidating at first, but they follow a consistent pattern. Learning to read them is essential.

Anatomy of a TypeScript error

error TS2345: Argument of type 'number' is not assignable
  to parameter of type 'string'.

  src/app.ts:12:15
    12   greet(42);
                ~~
PartMeaning
TS2345Error code — searchable on Google/docs
Argument of type 'number'What you gave
parameter of type 'string'What was expected
src/app.ts:12:15File, line, column
~~ under 42The exact code causing the error

Common errors and what they mean

ErrorPlain English
Type 'X' is not assignable to type 'Y'You are using a value of the wrong type
Property 'foo' does not exist on type 'Bar'Typo in property name, or the type is missing that property
Object is possibly 'undefined'You need to check for null/undefined before using it
Argument of type 'X' is not assignable to parameter of type 'Y'Function received the wrong argument type
Type 'X' is missing the following properties from type 'Y': a, bYour object is incomplete — add the missing properties
Cannot find name 'X'Variable/type not imported or not declared

Reading nested errors

Complex types produce nested errors. Read from bottom to top — the bottom line has the most specific problem:

Type '{ name: string; age: string; }' is not assignable to type 'User'.
  Types of property 'age' are incompatible.
    Type 'string' is not assignable to type 'number'.

Translation: "You gave age as a string, but User expects age to be a number."


5. The type system as documentation

Types replace much of what developers traditionally put in comments or JSDoc:

// WITHOUT types — you need comments to understand:
/**
 * Fetches user by ID
 * @param {number} id - The user's ID
 * @returns {Promise<{name: string, email: string} | null>} The user or null
 */
function getUser(id) { /* ... */ }

// WITH types — the signature IS the documentation:
function getUser(id: number): Promise<User | null> { /* ... */ }

Benefits over comments:

  • Types are enforced — comments can become stale
  • Types are checked by the compiler — comments are not
  • Types power IDE features — hover to see the type, click to go to the definition
  • Types cannot lie — if the function returns string, it must return string

6. Gradual typing — start loose, tighten over time

You do NOT need to type everything perfectly on day one. TypeScript supports gradual adoption:

// Stage 1: No types at all (plain JS renamed to .ts)
function processData(data) {    // 'data' is implicitly 'any'
  return data.map(item => item.name);
}

// Stage 2: Add basic types
function processData(data: any[]): string[] {
  return data.map(item => item.name);
}

// Stage 3: Define the shape
interface Item {
  name: string;
  value: number;
}

function processData(data: Item[]): string[] {
  return data.map(item => item.name);  // Full autocomplete now!
}

// Stage 4: Handle edge cases
function processData(data: Item[]): string[] {
  if (!Array.isArray(data)) {
    throw new TypeError("Expected an array of Items");
  }
  return data.map(item => item.name);
}

Gradual typing strategies:

  1. Rename .js to .ts — TypeScript infers what it can
  2. Fix errors one at a time — add types where the compiler complains
  3. Enable stricter options gradually — start with strict: false, then turn on individual checks
  4. Type public APIs first — function parameters and return types give the most value

7. any as an escape hatch

any tells TypeScript: "I don't know the type — skip checking." It is intentionally provided as an escape hatch, but overusing it defeats the purpose of TypeScript.

// 'any' disables ALL type checking for that value
let data: any = fetchSomething();
data.foo.bar.baz();        // No error — even if this crashes at runtime
data.nonExistentMethod();  // No error — TypeScript trusts you blindly
data = 42;
data = "hello";
data = { x: 1 };
// All valid — 'any' accepts and returns anything

When any is acceptable

  • Migration — converting a large JS codebase, any lets you compile now and fix later
  • Third-party data — when you truly do not know the shape (but prefer unknown)
  • Prototyping — quick experiments where type safety is not the goal yet

Why to minimize any

function processUser(user: any) {
  // No autocomplete
  // No error checking
  // If 'user' changes shape, no compiler warning
  console.log(user.nmae);  // Typo — no error with 'any'!
}

function processUser(user: User) {
  console.log(user.nmae);  // TS2551: Property 'nmae' does not exist. Did you mean 'name'?
}

Prefer unknown over anyunknown forces you to check the type before using the value (see 1.25.d).


8. Mental model: TypeScript as a linter on steroids

Think of TypeScript as an extremely powerful linter that runs before your code executes:

ToolWhat it checksWhen
ESLintCode style, patterns, potential bugsBefore/during development
TypeScriptData shapes, type compatibility, missing properties, null safetyBefore/during development
RuntimeActual execution — crashes, exceptionsAfter deployment

TypeScript pushes runtime errors into compile-time errors — which are cheaper and faster to fix:

Cost to fix a bug:

  Compile time  →  pennies  (red squiggle, fix immediately)
  Testing       →  dollars  (write test, debug, fix)
  Production    →  hundreds (users affected, incident response)

9. Real examples: planning functions by type signature first

Example 1: Shopping cart

// Think in types first:
interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}

type Cart = CartItem[];

// Now plan the API:
function addToCart(cart: Cart, item: CartItem): Cart { /* ... */ }
function removeFromCart(cart: Cart, productId: string): Cart { /* ... */ }
function getTotal(cart: Cart): number { /* ... */ }
function applyDiscount(cart: Cart, percent: number): Cart { /* ... */ }

// Before writing a single line of logic, you know:
// - What each function takes and returns
// - The shape of every piece of data
// - How functions compose together

Example 2: API layer

// Define what the API returns
interface ApiError {
  code: number;
  message: string;
}

type ApiResult<T> = 
  | { success: true; data: T }
  | { success: false; error: ApiError };

// Plan the functions
async function fetchUser(id: number): Promise<ApiResult<User>> { /* ... */ }
async function updateUser(id: number, updates: Partial<User>): Promise<ApiResult<User>> { /* ... */ }

// Usage is now type-safe:
const result = await fetchUser(123);
if (result.success) {
  console.log(result.data.name);  // TypeScript knows 'data' exists here
} else {
  console.error(result.error.message);  // TypeScript knows 'error' exists here
}

Example 3: Event system

// Define the event map
interface AppEvents {
  "user:login": { userId: string; timestamp: Date };
  "user:logout": { userId: string };
  "cart:update": { items: CartItem[]; total: number };
  "error": { message: string; code: number };
}

// Type-safe event emitter signature
function emit<K extends keyof AppEvents>(event: K, payload: AppEvents[K]): void { /* ... */ }
function on<K extends keyof AppEvents>(event: K, handler: (payload: AppEvents[K]) => void): void { /* ... */ }

// Now:
emit("user:login", { userId: "abc", timestamp: new Date() });  // Correct
emit("user:login", { userId: 123 });  // Error: 'number' not assignable to 'string'
emit("typo:event", {});               // Error: '"typo:event"' not in AppEvents

Key takeaways

  1. Think types first — define data shapes before writing logic.
  2. TypeScript errors follow a pattern: what you gave vs what was expected. Read nested errors bottom-to-top.
  3. Types serve as living documentation that the compiler enforces.
  4. Gradual typing lets you adopt TypeScript incrementally — start loose, tighten over time.
  5. Minimize any — it disables type checking and autocomplete. Prefer unknown when the type is uncertain.
  6. Think of TypeScript as a linter on steroids that moves runtime bugs to compile time.

Explain-It Challenge

Explain without notes:

  1. What does "types-first mindset" mean in practice?
  2. How would you read this error: Type 'string' is not assignable to type 'number'?
  3. Why is any called an "escape hatch," and when is it acceptable to use?

Navigation: ← 1.25 Overview · 1.25.c — Installing and Setting Up →