The Only TypeScript Article You'd Ever Need - 2/4

From Setup to Mastery: The Real TypeScript Journey Begins

Welcome back to your TypeScript awakening. If you followed Part 1, you’ve got the compiler humming and you’ve tasted the sweet satisfaction of catching errors before they ruin your day. But let’s be honest: you’ve only scratched the surface. Learning to run tsc is like learning to hold a paintbrush. What matters is what you create with it.

This is where TypeScript stops being “JavaScript with training wheels” and starts being your secret weapon for building bulletproof applications. You’re about to learn the vocabulary of reliable software: the types that prevent disasters, the patterns that scale, and the techniques that separate competent developers from those still debugging production at 3 AM.

Ready to stop writing code that might work and start writing code that will work? Let’s dive in.

The Foundation: 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 JavaScript. But TypeScript’s real power lies in the types that don’t exist in vanilla JS: the ones that capture the uncertainty, the impossibility, and the absence that plague every real-world application.

The Four Horsemen: any, unknown, void, and never

These aren’t just esoteric type theory concepts. They’re practical tools that address the messy reality of building software. Each one serves a specific purpose, and understanding when to use them (and when to run screaming in the other direction) is what separates TypeScript beginners from TypeScript professionals.

any: The Nuclear Option (Don’t Touch It)

any is TypeScript’s white flag of surrender. It’s saying, “Fine, I give up. Do whatever you want, and I’ll pretend everything is fine until it explodes at runtime.”

// 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 TypeScript is “being difficult,” or when you’re migrating legacy JavaScript. Resist this temptation like you’d resist clicking on suspicious email attachments. 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 JavaScript-to-TypeScript 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: The Responsible Adult

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: The Silent Worker

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: The Impossible Type

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.

Structuring Your Data: 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. TypeScript excels at making these structures predictable and safe.

Arrays: Collections with Standards

JavaScript arrays are Wild West data structures. They’ll accept anything you throw at them and sort it out at runtime (or die trying). TypeScript 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: Structure in the Chaos

This is where TypeScript 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: Quick and Dirty

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 = "alice@example.com"; // Error: email doesn't exist on the type

Optional Properties: When Not Everything Is Required

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: Immutable by Design

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: type vs interface

Here’s where every TypeScript tutorial gets bogged down in philosophical debates. 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: The Traditional Choice

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

type: The Flexible Alternative

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: Let TypeScript Do the Heavy Lifting

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: The Heart of Your Application Logic

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", "bob@example.com");
createUser("Charlie", "charlie@example.com", "admin");

Rest Parameters: Handling Variable Arguments

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: Object-Oriented TypeScript with Real Protection

JavaScript classes were a nice addition to the language, but they lacked real encapsulation. TypeScript classes come with actual access control that’s enforced at compile time.

Access Modifiers: The Bouncers for Your Data

public: Everyone’s Invited

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: Internal Business Only

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: Family Business

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: TypeScript’s Sweet Syntactic Sugar

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: The Ultimate Reusability Tool

Here’s where TypeScript transforms from “JavaScript with types” to “a genuinely powerful programming language.” 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 to Rule Them All

// 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: Taming the Wild T

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: "alice@example.com" };
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: Reusable Data Structures

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: "alice@example.com" });

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: Flexible Contracts

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: Real-World Application

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: "Alice Johnson",
    email: "alice@example.com",
    role: "user",
  })
  .then((user) => {
    console.log(`Created user: ${user.name} (${user.id})`);
  });

This example demonstrates:

  • 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 You’ve Mastered (And 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 JavaScript. You’re designing with types, thinking about contracts, and building applications that scale. That’s a fundamental shift in how you approach software development.

But we’re not done yet. In Part 3, we’ll dive into:

  • Advanced type manipulation: Mapped types, conditional types, and template literals
  • Utility types that’ll save you hours: Pick, Omit, Record, and friends
  • Real-world patterns: Discriminated unions, brand types, and builder patterns
  • Error handling strategies: Result types and safe async operations

And in Part 4, we’ll put it all together with:

  • TypeScript + React: Component props, hooks, and context with bulletproof typing
  • Node.js APIs: Express middleware, database types, and configuration management
  • Project architecture: Monorepos, declaration files, and build optimization
  • Testing strategies: Type-safe testing with proper mocking

You’ve built the foundation. Now let’s build the skyscraper.

Ready to become the TypeScript developer your team actually trusts with the important code? Part 3 is where the real magic happens.