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);
~~
| Part | Meaning |
|---|---|
TS2345 | Error code — searchable on Google/docs |
Argument of type 'number' | What you gave |
parameter of type 'string' | What was expected |
src/app.ts:12:15 | File, line, column |
~~ under 42 | The exact code causing the error |
Common errors and what they mean
| Error | Plain 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, b | Your 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 returnstring
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:
- Rename
.jsto.ts— TypeScript infers what it can - Fix errors one at a time — add types where the compiler complains
- Enable stricter options gradually — start with
strict: false, then turn on individual checks - 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,
anylets 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 any — unknown 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:
| Tool | What it checks | When |
|---|---|---|
| ESLint | Code style, patterns, potential bugs | Before/during development |
| TypeScript | Data shapes, type compatibility, missing properties, null safety | Before/during development |
| Runtime | Actual execution — crashes, exceptions | After 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
- Think types first — define data shapes before writing logic.
- TypeScript errors follow a pattern: what you gave vs what was expected. Read nested errors bottom-to-top.
- Types serve as living documentation that the compiler enforces.
- Gradual typing lets you adopt TypeScript incrementally — start loose, tighten over time.
- Minimize
any— it disables type checking and autocomplete. Preferunknownwhen the type is uncertain. - Think of TypeScript as a linter on steroids that moves runtime bugs to compile time.
Explain-It Challenge
Explain without notes:
- What does "types-first mindset" mean in practice?
- How would you read this error:
Type 'string' is not assignable to type 'number'? - Why is
anycalled an "escape hatch," and when is it acceptable to use?
Navigation: ← 1.25 Overview · 1.25.c — Installing and Setting Up →