The Only TypeScript Article You'd Ever Need - 2/4
Welcome to Your TypeScript Journey
Welcome (back), if you followed Part 1, you’ve got the compiler humming and you’ve tasted the satisfaction of catching errors before they ruin your day. You’ve barely even scratched the surface till now though.
This is where TS stops being “JS with training wheels” and starts being your secret weapon for building bulletproof applications. You’re about to learn about building reliable software: why types matter, the techniques that separate competent developers from those still debugging production at 3 AM, etc.
Ready to stop writing code that might work and start writing code that will work? Let’s dive in.
Types That Actually Matter
You know primitives. You’ve met string, number, and boolean. They’re the easy ones, the types that make you feel comfortable because they mirror what you already know from JS. But TypeScript’s real power lies in the types that don’t exist in vanilla JS: the ones that capture the uncertainty or the impossibility that plague many applications.
The Four Horsemen
any, unknown, void, and never.
Each one serves a specific purpose, and understanding when to use them (and when to run screaming in the other direction) is really important.
any
any is the white flag of surrender. It’s saying, “Fine, I give up. Do whatever you want.”
// This is admitting defeat
let data: any = "hello";
data = 10; // No error
data.toFixed(); // No compile-time error, but crashes if data is a string
data.nonExistentMethod(); // TypeScript shrugs, runtime screams
You’ll be tempted to use any when you’re in a hurry, when TS is being “difficult,” or when you’re migrating legacy JS code to TS. Resist this temptation. Every any in your codebase is a potential bug waiting to happen.
When is any acceptable? Almost never. The only legitimate uses are during large JS-to-TS migrations as a temporary placeholder, or when dealing with truly dynamic content where you absolutely cannot predict the type (this is rarer than you think).
unknown
unknown is what any should have been. It’s TypeScript saying, “I’ll hold anything you give me, but you can’t touch it until you prove to me what it is.”
let userInput: unknown;
userInput = "hello";
userInput = 42;
userInput = { name: "Alice" };
// This won't compile - TypeScript demands proof
// console.log(userInput.toUpperCase()); // Error!
// But this will work - you've proven the type
if (typeof userInput === "string") {
console.log(userInput.toUpperCase()); // TypeScript knows it's a string now
}
// For objects, you need more thorough checking
if (
typeof userInput === "object" &&
userInput !== null &&
"name" in userInput
) {
console.log((userInput as { name: string }).name);
}
When to use unknown: When you’re dealing with data from APIs, user input, or dynamic imports where you genuinely don’t know the type at compile time, but you want to handle it safely.
void
void represents functions that do work but don’t return anything meaningful. Think of it as the strong, silent type that gets things done without making a fuss.
function logUserAction(action: string): void {
console.log(`User performed: ${action}`);
// Implicitly returns undefined, which is fine for void
}
function sendAnalytics(eventName: string, data: object): void {
// Send to analytics service
// No return value needed - we're just performing a side effect
}
When to use void: For functions that perform side effects (logging, DOM manipulation, API calls) but don’t produce a value that consumers need to use.
never
never represents values that literally never occur. It’s TypeScript’s way of saying, “If execution reaches here, something has gone fundamentally wrong with your logic.”
function throwError(message: string): never {
throw new Error(message);
// This function never returns normally - it always throws
}
// Exhaustive type checking
type Theme = "light" | "dark" | "auto";
function applyTheme(theme: Theme): void {
switch (theme) {
case "light":
document.body.className = "light-theme";
break;
case "dark":
document.body.className = "dark-theme";
break;
case "auto":
document.body.className = "auto-theme";
break;
default:
// If you add a new Theme type and forget to handle it here,
// TypeScript will complain that 'theme' is of type 'never'
const exhaustiveCheck: never = theme;
throw new Error(`Unhandled theme: ${exhaustiveCheck}`);
}
}
When to use never: For functions that always throw exceptions, infinite loops, or exhaustive type checking to ensure you’ve handled all possible cases.
Arrays and Objects That Make Sense
Now that you understand the philosophical types, let’s talk about the practical ones. Your applications don’t just work with individual values; they work with collections and structured data. TS excels at making these structures predictable and safe.
Arrays
JS arrays are Wild West data structures. They’ll accept anything you throw at them and sort it out at runtime (or die trying). TS arrays have standards.
// Clean, predictable arrays
let userIds: number[] = [1, 2, 3, 4];
let userNames: string[] = ["Alice", "Bob", "Charlie"];
// TypeScript prevents the chaos
// userIds.push("five"); // Error: string is not assignable to number
// userNames[1] = 42; // Error: number is not assignable to string
// Sometimes you need mixed types (use unions)
let mixedData: (string | number)[] = ["user-1", 25, "user-2", 30];
mixedData.push("valid"); // OK
mixedData.push(42); // OK
// mixedData.push(true); // Error: boolean is not assignable
Alternative syntax:
let scores: Array<number> = [95, 87, 92]; // Generic syntax
let messages: Array<string> = ["Hello", "World"];
Both syntaxes work identically. Pick one and be consistent. Most developers prefer type[] for simple arrays because it’s more concise.
Objects
This is where TS starts to really flex. Object types let you define the exact shape of your data structures, preventing the “property doesn’t exist” errors that haunt JavaScript developers.
Inline Object Types
For simple, one-off objects:
let user: { name: string; age: number; isActive: boolean } = {
name: "Alice",
age: 30,
isActive: true,
};
// TypeScript enforces the contract
// user.name = 123; // Error: number is not assignable to string
// user.email = "[email protected]"; // Error: email doesn't exist on the type
Optional Properties
Real-world objects often have optional data. Use ? to make properties optional:
let config: { apiUrl: string; timeout?: number; debug?: boolean } = {
apiUrl: "https://api.example.com",
// timeout and debug are optional
};
config.timeout = 5000; // OK
config.debug = true; // OK
// config.timeout = "5 seconds"; // Error: string is not assignable to number
Readonly Properties
Some properties should never change after initialization:
let product: { readonly id: string; name: string; price: number } = {
id: "prod-123",
name: "Laptop",
price: 999,
};
// product.id = "new-id"; // Error: Cannot assign to 'id' because it's read-only
product.name = "Gaming Laptop"; // OK - name is not readonly
The Great Debate
It’s type vs interface.
Here’s where every TS tutorial gets bogged down. Let me cut through the noise with practical advice.
Both type aliases and interface declarations define object shapes. For most use cases, they’re interchangeable. The difference matters in specific scenarios.
Interface
Interfaces are primarily for defining object shapes and have one superpower: declaration merging.
interface User {
id: string;
name: string;
email?: string;
}
// Later in your code (or in a different file)
interface User {
// This automatically merges with the previous User interface
lastLoginDate?: Date;
}
// Now User has all properties: id, name, email, lastLoginDate
const user: User = {
id: "1",
name: "Alice",
lastLoginDate: new Date(),
};
Use interfaces when:
- Defining object shapes
- Working with libraries where you might need to extend types
- You want declaration merging capabilities
Types
Type aliases can define any type, not just objects:
// Object shapes (same as interface)
type Product = {
id: string;
name: string;
price: number;
};
// But also primitive aliases
type UserId = string;
type Timestamp = number;
// Union types (interfaces can't do this directly)
type Status = "pending" | "approved" | "rejected";
type Theme = "light" | "dark";
// Complex combinations
type ApiResponse<T> = {
data: T;
status: Status;
timestamp: Timestamp;
};
Use type aliases when:
- Creating union types
- Aliasing primitive types
- Complex type manipulations
- You prefer the flexibility
The Practical Choice
Stop overthinking this. Pick one approach and be consistent within your project. Many teams use interfaces for object shapes and types for everything else. The important thing is that you’re defining your data structures explicitly, not which keyword you use.
Type Inference
TypeScript’s type inference engine is sophisticated. It can often figure out types without you explicitly declaring them. This keeps your code clean while maintaining type safety.
// TypeScript infers these types automatically
let message = "Hello TypeScript!"; // inferred as string
let count = 42; // inferred as number
let isComplete = false; // inferred as boolean
let numbers = [1, 2, 3]; // inferred as number[]
// Objects get inferred shapes
let person = {
name: "Alice",
age: 30,
}; // inferred as { name: string; age: number; }
// Functions can infer return types
function multiply(a: number, b: number) {
return a * b; // return type inferred as number
}
When to Annotate vs When to Let It Infer
Let TypeScript infer when:
- Variables are initialized immediately with obvious values
- The type is clear from context
- It keeps your code clean and readable
Annotate explicitly when:
- Function parameters (TypeScript can’t infer these)
- Variables declared without immediate initialization
- You want to enforce a specific contract
- Complex object shapes that will be reused
// Good inference
let config = { timeout: 5000, retries: 3 };
// Explicit annotation needed
function processUser(userData: unknown): User {
// Process the data...
return validateUser(userData);
}
// Explicit annotation for clarity
let apiResponse: ApiResponse<User[]>;
// Later...
apiResponse = await fetchUsers();
Functions
Functions are where your types really matter. A well-typed function is a contract that says exactly what it expects and what it provides. No surprises, no runtime explosions.
Function Declarations and Expressions
// Function declaration with explicit types
function calculateTax(price: number, rate: number): number {
return price * rate;
}
// Function expression
const formatCurrency = function (
amount: number,
currency: string = "USD"
): string {
return `${amount.toFixed(2)} ${currency}`;
};
// Arrow function
const processOrder = (orderId: string, items: Item[]): OrderResult => {
// Processing logic...
return { success: true, orderId, total: calculateTotal(items) };
};
Optional and Default Parameters
Real functions often have flexible parameter requirements:
function createUser(name: string, email?: string, role: string = "user"): User {
return {
id: generateId(),
name,
email: email || undefined,
role,
createdAt: new Date(),
};
}
// All of these are valid calls
createUser("Alice");
createUser("Bob", "[email protected]");
createUser("Charlie", "[email protected]", "admin");
Rest Parameters
When you need to accept an unknown number of arguments:
function logMessages(category: string, ...messages: string[]): void {
messages.forEach((msg) => console.log(`[${category.toUpperCase()}] ${msg}`));
}
logMessages("ERROR", "Database connection failed");
logMessages("INFO", "User logged in", "Session created", "Preferences loaded");
Function Type Signatures
For reusable function contracts:
// Define the shape of callback functions
type EventHandler = (eventName: string, data: any) => void;
type Validator<T> = (value: T) => boolean;
type Transformer<T, U> = (input: T) => U;
// Use them in function parameters
function addEventListener(event: string, handler: EventHandler): void {
// Event handling logic
}
function validateInput<T>(value: T, validator: Validator<T>): boolean {
return validator(value);
}
Classes
JS classes were a nice addition to the language, but they lacked real encapsulation. TS classes come with actual access control that’s enforced at compile time.
Access Modifiers
public
class BankAccount {
public accountHolder: string; // Accessible everywhere
public accountType: string;
constructor(holder: string, type: string) {
this.accountHolder = holder;
this.accountType = type;
}
public getAccountInfo(): string {
return `${this.accountHolder} - ${this.accountType}`;
}
}
const account = new BankAccount("Alice", "Checking");
console.log(account.accountHolder); // OK - public property
private
class BankAccount {
public accountHolder: string;
private balance: number; // Only accessible within this class
constructor(holder: string, initialBalance: number) {
this.accountHolder = holder;
this.balance = initialBalance;
}
public deposit(amount: number): void {
if (amount > 0) {
this.balance += amount; // OK - accessing private property within the class
}
}
public getBalance(): number {
return this.balance; // OK - controlled access to private data
}
private validateTransaction(amount: number): boolean {
return amount > 0 && amount <= this.balance;
}
}
const account = new BankAccount("Bob", 1000);
account.deposit(100); // OK
console.log(account.getBalance()); // OK
// console.log(account.balance); // Error: 'balance' is private
protected
class Vehicle {
protected engine: string;
protected maxSpeed: number;
constructor(engine: string, maxSpeed: number) {
this.engine = engine;
this.maxSpeed = maxSpeed;
}
protected startEngine(): void {
console.log(`${this.engine} engine starting...`);
}
}
class Car extends Vehicle {
constructor(engine: string, maxSpeed: number, public brand: string) {
super(engine, maxSpeed);
}
public drive(): void {
this.startEngine(); // OK - accessing protected method from parent
console.log(`Driving ${this.brand} with ${this.engine} engine`);
}
public getSpecs(): string {
return `${this.engine}, Max Speed: ${this.maxSpeed} mph`; // OK - protected properties
}
}
const car = new Car("V6", 120, "Honda");
car.drive(); // OK
// car.startEngine(); // Error: 'startEngine' is protected
Constructor Shortcuts
Instead of declaring properties and then assigning them in the constructor, you can do both at once:
// The long way
class Product {
public name: string;
public price: number;
private inventory: number;
constructor(name: string, price: number, inventory: number) {
this.name = name;
this.price = price;
this.inventory = inventory;
}
}
// The TypeScript shortcut
class Product {
constructor(
public name: string,
public price: number,
private inventory: number
) {
// Properties are automatically created and assigned
}
}
Generics
Generics let you write code that works with multiple types while maintaining type safety. It’s like writing a template that gets filled in with specific types when you use it.
The Problem Generics Solve
Without generics, you’d write redundant code for each type:
// Pathetic repetition
function getFirstString(items: string[]): string {
return items[0];
}
function getFirstNumber(items: number[]): number {
return items[0];
}
function getFirstUser(items: User[]): User {
return items[0];
}
// ... and on and on for every type you can imagine
Generic Functions
// One function that works with any type
function getFirst<T>(items: T[]): T {
return items[0];
}
// TypeScript infers the type from usage
const firstUser = getFirst([user1, user2, user3]); // T inferred as User
const firstNumber = getFirst([1, 2, 3, 4]); // T inferred as number
const firstMessage = getFirst(["hello", "world"]); // T inferred as string
// You can also be explicit about the type
const firstProduct = getFirst<Product>([product1, product2]);
Generic Constraints
Sometimes your generic function needs to perform operations that only work on certain types. Generic constraints let you specify requirements:
// Constraint: T must have a length property
interface Lengthwise {
length: number;
}
function getLength<T extends Lengthwise>(item: T): number {
return item.length; // OK because T is guaranteed to have length
}
getLength("hello"); // OK - string has length
getLength([1, 2, 3]); // OK - array has length
// getLength(42); // Error - number doesn't have length
// Keyof constraint for safe property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30, email: "[email protected]" };
const userName = getProperty(user, "name"); // string
const userAge = getProperty(user, "age"); // number
// const invalid = getProperty(user, "salary"); // Error: salary doesn't exist on user
Generic Classes
class DataStore<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
get(index: number): T | undefined {
return this.items[index];
}
find(predicate: (item: T) => boolean): T | undefined {
return this.items.find(predicate);
}
getAll(): T[] {
return [...this.items]; // Return a copy for safety
}
}
// Strongly typed storage for different types
const userStore = new DataStore<User>();
userStore.add({ id: "1", name: "Alice", email: "[email protected]" });
const productStore = new DataStore<Product>();
productStore.add({ id: "p1", name: "Laptop", price: 999 });
// Type safety maintained
const user = userStore.find((u) => u.name === "Alice"); // user is User | undefined
const product = productStore.find((p) => p.price > 500); // product is Product | undefined
Generic Interfaces
interface ApiResponse<T> {
data: T;
success: boolean;
message: string;
timestamp: number;
}
// Usage with different data types
const userResponse: ApiResponse<User> = {
data: { id: "1", name: "Alice" },
success: true,
message: "User retrieved successfully",
timestamp: Date.now(),
};
const usersResponse: ApiResponse<User[]> = {
data: [user1, user2, user3],
success: true,
message: "Users retrieved successfully",
timestamp: Date.now(),
};
Bringing It All Together
Let’s combine everything you’ve learned into a practical example that demonstrates how these concepts work together:
// Domain types
interface User {
readonly id: string;
name: string;
email: string;
role: "admin" | "user" | "moderator";
}
interface Product {
readonly id: string;
name: string;
price: number;
category: string;
}
// Generic repository pattern
class Repository<T extends { id: string }> {
private items = new Map<string, T>();
add(item: T): void {
this.items.set(item.id, item);
}
findById(id: string): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
update(id: string, updates: Partial<Omit<T, "id">>): T | undefined {
const item = this.items.get(id);
if (item) {
const updated = { ...item, ...updates };
this.items.set(id, updated);
return updated;
}
return undefined;
}
delete(id: string): boolean {
return this.items.delete(id);
}
}
// Service layer with proper error handling
class UserService {
constructor(private userRepo: Repository<User>) {}
async createUser(userData: Omit<User, "id">): Promise<User> {
const user: User = {
id: this.generateId(),
...userData,
};
this.userRepo.add(user);
return user;
}
async updateUser(
id: string,
updates: Partial<Pick<User, "name" | "email">>
): Promise<User | null> {
const updated = this.userRepo.update(id, updates);
return updated || null;
}
private generateId(): string {
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
// Usage
const userRepo = new Repository<User>();
const userService = new UserService(userRepo);
// Type-safe operations
userService
.createUser({
name: "Sanchit Bhalla",
email: "[email protected]",
role: "user",
})
.then((user) => {
console.log(`Created user: ${user.name} (${user.id})`);
});
This example shows:
- Interface definitions for domain objects
- Generic constraints (
T extends { id: string }) - Utility types (
Partial,Omit,Pick) - Private class members for encapsulation
- Proper separation of concerns
- Type-safe async operations
What’s Next
Congratulations. You’ve moved from someone who can type annotations to someone who thinks in types. You understand the difference between unknown and any (and why that matters). You can build flexible, reusable components with generics. You know when to use interfaces vs types, and more importantly, why it doesn’t matter as much as some developers think it does.
You’re no longer just adding type annotations to JS, you’re starting to design with types, think about contracts, and build applications that are easier to manage. That’s a fundamental shift in how you approach software development.
In the next part I’ll dive deeper into managing types, having more reusability, etc.
See you there, learner :)