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

Welcome to Type Wizardry: Where TypeScript Gets Actually Interesting

You’ve survived the basics. You can write functions with proper types, build classes that don’t leak implementation details, and wield generics like a semi-competent developer. Good for you. But here’s the thing: you’re still just playing with building blocks.

What if I told you that TypeScript isn’t just about adding type annotations to JavaScript? What if the real power lies in making types that compute, combine, and transform based on other types? What if you could write type-level logic that prevents entire categories of bugs before they even have a chance to exist?

Welcome to advanced TypeScript, where types become programmable logic and your compiler becomes your most trusted code reviewer. This is where you stop being someone who uses TypeScript and start being someone who thinks in types.

Ready to bend the type system to your will? Let’s get dangerous.

Union and Intersection Types: The Art of Type Combination

Think of types like LEGO pieces. Basic types are your standard rectangular blocks. But what if you need something more complex? What if you need a piece that could be either a red block or a blue block? Or a piece that’s both a block and a wheel?

That’s where union and intersection types come in. They’re not just syntax; they’re the foundation of expressing complex, real-world relationships in your code.

Union Types (|): The “Either/Or” of Type Safety

A union type says “this value can be one of several different types.” It’s like having a function parameter that could accept multiple forms of input without losing type safety.

// Instead of this mess:
function processId(id: any) {
  if (typeof id === "string") {
    return id.toUpperCase();
  } else {
    return id.toString();
  }
}

// Write this:
function processId(id: string | number): string {
  if (typeof id === "string") {
    return id.toUpperCase(); // TypeScript knows it's a string here
  } else {
    return id.toString(); // TypeScript knows it's a number here
  }
}

processId("abc-123"); // ✅ Works
processId(42); // ✅ Works
// processId(true); // ❌ TypeScript catches this

But unions really shine when modeling real-world data that comes in different shapes:

interface LoadingState {
  status: "loading";
}

interface SuccessState {
  status: "success";
  data: User[];
}

interface ErrorState {
  status: "error";
  message: string;
  code: number;
}

// Your API response can be exactly one of these states
type ApiState = LoadingState | SuccessState | ErrorState;

function handleApiResponse(state: ApiState) {
  switch (state.status) {
    case "loading":
      showSpinner();
      break;
    case "success":
      // TypeScript knows state.data exists and is User[]
      displayUsers(state.data);
      break;
    case "error":
      // TypeScript knows state.message and state.code exist
      showError(`Error ${state.code}: ${state.message}`);
      break;
    default:
      // TypeScript will complain if you add a new state but forget to handle it here
      const exhaustive: never = state;
      throw new Error(`Unhandled state: ${exhaustive}`);
  }
}

Notice what happened there? By using union types with discriminating properties (status), TypeScript automatically narrows the type in each branch. No more guessing what properties exist. No more runtime explosions because you tried to access data on an error state.

Intersection Types (&): The “Both/And” of Type Composition

While unions say “this OR that,” intersections say “this AND that.” They combine multiple types into a single type that has all the properties of its constituent parts.

interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

interface Identifiable {
  id: string;
}

interface User {
  name: string;
  email: string;
}

// A database entity is a User AND has timestamps AND has an ID
type DatabaseUser = User & Timestamped & Identifiable;

const dbUser: DatabaseUser = {
  id: "user-123",
  name: "Alice",
  email: "alice@example.com",
  createdAt: new Date("2023-01-01"),
  updatedAt: new Date("2023-06-01"),
}; // Must have ALL properties from all three types

Intersections are particularly powerful for creating configurable interfaces:

interface BaseConfig {
  apiUrl: string;
  timeout: number;
}

interface DatabaseConfig {
  host: string;
  port: number;
  database: string;
}

interface CacheConfig {
  redis: {
    host: string;
    port: number;
  };
}

// A production config needs all of these
type ProductionConfig = BaseConfig & DatabaseConfig & CacheConfig;

// A development config might only need the basics
type DevelopmentConfig = BaseConfig & Partial<DatabaseConfig>;

Literal Types and Enums: Stop Using Magic Strings (Please)

You know what’s worse than a runtime error? A runtime error caused by a typo in a string that could have been caught at compile time. If you’re still using raw strings for values that have specific meaning, you’re essentially writing bugs with extra steps.

Literal Types: Precision Over Flexibility

Literal types let you specify exact values, not just general types:

// Instead of allowing any string:
function setAlignment(align: string) {
  // Hope the caller passes "left", "center", or "right"
  // Spoiler alert: they'll pass "centre" and wonder why it breaks
}

// Be specific about what you accept:
function setAlignment(align: "left" | "center" | "right") {
  // TypeScript ensures only valid alignments
}

setAlignment("left"); // ✅ Works
setAlignment("center"); // ✅ Works
// setAlignment("centre"); // ❌ TypeScript catches British spelling mistakes

You can combine literal types with unions to create powerful, self-documenting APIs:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;

interface ApiRequest {
  method: HttpMethod;
  endpoint: string;
  expectedStatus: StatusCode;
}

function makeRequest(request: ApiRequest) {
  // TypeScript ensures method and expectedStatus are valid
  // No more wondering if someone typo'd "PSOT" instead of "POST"
}

Enums: When You Need More Structure

For related constants, enums provide better organization and can carry additional metadata:

enum UserRole {
  ADMIN = "admin",
  MODERATOR = "moderator",
  USER = "user",
  GUEST = "guest",
}

enum HttpStatusCode {
  OK = 200,
  CREATED = 201,
  BAD_REQUEST = 400,
  UNAUTHORIZED = 401,
  NOT_FOUND = 404,
  INTERNAL_SERVER_ERROR = 500,
}

// Now your code is self-documenting
function checkPermission(userRole: UserRole): boolean {
  return userRole === UserRole.ADMIN || userRole === UserRole.MODERATOR;
}

function handleApiResponse(statusCode: HttpStatusCode) {
  if (statusCode === HttpStatusCode.OK) {
    // Handle success
  } else if (statusCode >= HttpStatusCode.BAD_REQUEST) {
    // Handle client/server errors
  }
}

Pro tip: Use string enums for values you’ll debug (UserRole.ADMIN shows up as "admin" in logs), and numeric enums when you need bitwise operations or performance is critical.

Type Guards: Teaching TypeScript About Runtime Reality

TypeScript’s type system works at compile time, but your data comes from the runtime world of APIs, user input, and cosmic rays flipping bits in your RAM. Type guards are how you bridge that gap safely.

Built-in Type Guards: The Classics

The typeof, instanceof, and in operators become type guards when used in conditional statements:

function processValue(value: string | number | Date) {
  if (typeof value === "string") {
    // TypeScript knows value is string
    return value.trim().toUpperCase();
  }

  if (typeof value === "number") {
    // TypeScript knows value is number
    return value.toFixed(2);
  }

  if (value instanceof Date) {
    // TypeScript knows value is Date
    return value.toISOString();
  }

  // TypeScript knows this is unreachable
  const exhaustive: never = value;
  throw new Error(`Unexpected value: ${exhaustive}`);
}

For object types, use the in operator:

interface Bird {
  fly(): void;
  wingspan: number;
}

interface Fish {
  swim(): void;
  finCount: number;
}

function moveAnimal(animal: Bird | Fish) {
  if ("fly" in animal) {
    // TypeScript knows it's a Bird
    animal.fly();
    console.log(`Wingspan: ${animal.wingspan}`);
  } else {
    // TypeScript knows it's a Fish
    animal.swim();
    console.log(`Fins: ${animal.finCount}`);
  }
}

Custom Type Guards: When Built-ins Aren’t Enough

Sometimes you need more sophisticated runtime checks. Custom type guards use the is keyword to teach TypeScript about your validation logic:

interface ValidUser {
  id: string;
  email: string;
  name: string;
}

// A type predicate function
function isValidUser(data: unknown): data is ValidUser {
  return (
    typeof data === "object" &&
    data !== null &&
    typeof (data as any).id === "string" &&
    typeof (data as any).email === "string" &&
    typeof (data as any).name === "string" &&
    (data as any).email.includes("@")
  );
}

// Now you can safely handle uncertain data
function handleApiData(data: unknown) {
  if (isValidUser(data)) {
    // TypeScript knows data is ValidUser
    console.log(`Welcome, ${data.name}!`);
    sendEmail(data.email);
  } else {
    throw new Error("Invalid user data");
  }
}

// Parse JSON safely
try {
  const userData = JSON.parse(someJsonString);
  handleApiData(userData); // userData is unknown, but that's okay
} catch (error) {
  console.error("Failed to parse user data");
}

This pattern is essential for dealing with external data. Never trust, always verify.

Type Assertions: The “Trust Me, Bro” Operator

Sometimes TypeScript’s type inference isn’t sophisticated enough to understand what you know to be true. Type assertions (as) let you override the compiler’s judgment, but with great power comes great responsibility (to not shoot yourself in the foot).

Safe Type Assertions

Use assertions when you have information the compiler lacks:

// DOM manipulation - TypeScript can't know the specific element type
const canvas = document.getElementById("game-canvas") as HTMLCanvasElement;
const context = canvas.getContext("2d"); // Now TypeScript knows canvas has canvas-specific methods

// After validation with a type guard
function processFormData(formData: FormData) {
  const email = formData.get("email") as string; // FormData.get returns string | File | null
  // Only do this if you've validated the form beforehand!
}

// When working with libraries that have incomplete types
const config = JSON.parse(configString) as AppConfig;
// Better: use a type guard to validate first

Dangerous Type Assertions (Don’t Do These)

// Lying to the compiler
const userAge = "25" as number; // Runtime disaster waiting to happen
// userAge.toFixed(2); // "25".toFixed is not a function

// Asserting complex types without validation
const apiResponse = someUnknownData as ComplexUserProfile;
// If apiResponse is missing required properties, you're in trouble

// Using assertions to silence errors instead of fixing them
const result = riskyOperation() as SuccessResult;
// If riskyOperation() can fail, this assertion is wishful thinking

Rule of thumb: If you need to assert a type, first ask yourself if you should be using a type guard instead. Assertions should be rare and used only when you have information that TypeScript cannot infer.

Advanced Type Sorcery: Where Things Get Interesting

Congratulations, you’ve mastered the fundamentals of TypeScript’s type system. Now let’s talk about the really fun stuff: types that compute other types.

keyof: X-Ray Vision for Object Types

The keyof operator extracts the keys of an object type as a literal union:

interface ApiEndpoints {
  users: "/api/users";
  posts: "/api/posts";
  comments: "/api/comments";
}

type EndpointNames = keyof ApiEndpoints; // "users" | "posts" | "comments"

// Use this for type-safe property access
function getEndpoint<T extends keyof ApiEndpoints>(name: T): ApiEndpoints[T] {
  const endpoints: ApiEndpoints = {
    users: "/api/users",
    posts: "/api/posts",
    comments: "/api/comments",
  };
  return endpoints[name];
}

const usersUrl = getEndpoint("users"); // Type is "/api/users"
// const invalid = getEndpoint("profiles"); // Error: not a valid key

Mapped Types: Type-Level Loops

Mapped types let you transform existing types by iterating over their properties:

// Make all properties optional (this is how Partial<T> works)
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Transform all properties to strings (for form data)
type Stringify<T> = {
  [K in keyof T]: string;
};

interface User {
  id: number;
  name: string;
  isActive: boolean;
}

type UserForm = Stringify<User>;
// Result: { id: string; name: string; isActive: string; }

You can even add or remove modifiers:

// Remove readonly modifiers
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// Remove optional modifiers (make everything required)
type Required<T> = {
  [K in keyof T]-?: T[K];
};

Conditional Types: Type-Level If Statements

The most brain-bending feature of TypeScript’s type system. Conditional types let you write logic that executes at the type level:

// Basic conditional type
type IsString<T> = T extends string ? "yes" : "no";

type Test1 = IsString<"hello">; // "yes"
type Test2 = IsString<42>; // "no"

// Extract function return types
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser(id: string): { name: string; email: string } {
  return { name: "Alice", email: "alice@example.com" };
}

type UserType = ReturnType<typeof getUser>; // { name: string; email: string }

The infer keyword is particularly powerful for extracting types from complex type structures:

// Extract the element type from an array
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type StringArray = string[];
type ElementType = ArrayElement<StringArray>; // string

// Extract promise resolved types
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type AsyncUserType = UnwrapPromise<Promise<User>>; // User
type SyncUserType = UnwrapPromise<User>; // User (no change)

Built-in Utility Types: The Standard Library of Type Magic

TypeScript comes with a rich set of utility types built from the primitives we’ve discussed. Understanding how they work internally makes you a more effective TypeScript developer.

The Essential Utilities

Partial<T> - Makes all properties optional:

interface UserConfig {
  theme: "dark" | "light";
  notifications: boolean;
  language: string;
}

function updateUserConfig(config: UserConfig, updates: Partial<UserConfig>) {
  return { ...config, ...updates };
}

updateUserConfig(currentConfig, { theme: "dark" }); // Only updating theme

Required<T> - Makes all properties required:

interface OptionalUser {
  name?: string;
  email?: string;
  age?: number;
}

type CompleteUser = Required<OptionalUser>;
// Result: { name: string; email: string; age: number; }

Pick<T, K> - Select specific properties:

interface BlogPost {
  id: string;
  title: string;
  content: string;
  author: string;
  publishedAt: Date;
  views: number;
}

type BlogSummary = Pick<BlogPost, "id" | "title" | "author">;
// Result: { id: string; title: string; author: string; }

Omit<T, K> - Exclude specific properties:

type CreateUserRequest = Omit<User, "id" | "createdAt" | "updatedAt">;
// User minus the database-generated fields

Parameters<T> - Extract function parameter types:

function authenticate(
  username: string,
  password: string,
  rememberMe?: boolean
) {
  // Authentication logic
}

type AuthParams = Parameters<typeof authenticate>;
// [username: string, password: string, rememberMe?: boolean]

// Useful for creating wrapper functions
function safeAuthenticate(...args: AuthParams) {
  try {
    return authenticate(...args);
  } catch (error) {
    console.error("Authentication failed:", error);
    return null;
  }
}

ReturnType<T> - Extract function return type:

async function fetchUserProfile(id: string) {
  return {
    user: { id, name: "Alice", email: "alice@example.com" },
    preferences: { theme: "dark", language: "en" },
  };
}

type UserProfile = ReturnType<typeof fetchUserProfile>;
// Promise<{ user: {...}, preferences: {...} }>

type UserProfileData = Awaited<ReturnType<typeof fetchUserProfile>>;
// { user: {...}, preferences: {...} }

Real-World Type Engineering: Putting It All Together

Let’s build something practical that demonstrates these concepts working together:

// API Response modeling with discriminated unions
interface ApiSuccess<T> {
  status: "success";
  data: T;
  timestamp: Date;
}

interface ApiError {
  status: "error";
  message: string;
  code: number;
  timestamp: Date;
}

interface ApiLoading {
  status: "loading";
  timestamp: Date;
}

type ApiResponse<T> = ApiSuccess<T> | ApiError | ApiLoading;

// Generic API client with type safety
class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async request<T>(endpoint: string): Promise<ApiResponse<T>> {
    try {
      const response = await fetch(`${this.baseUrl}${endpoint}`);

      if (!response.ok) {
        return {
          status: "error",
          message: response.statusText,
          code: response.status,
          timestamp: new Date(),
        };
      }

      const data = await response.json();
      return {
        status: "success",
        data,
        timestamp: new Date(),
      };
    } catch (error) {
      return {
        status: "error",
        message: error instanceof Error ? error.message : "Unknown error",
        code: 0,
        timestamp: new Date(),
      };
    }
  }
}

// Usage with full type safety
interface User {
  id: string;
  name: string;
  email: string;
}

const client = new ApiClient("https://api.example.com");

async function loadUser(id: string) {
  const response = await client.request<User>(`/users/${id}`);

  switch (response.status) {
    case "loading":
      showSpinner();
      break;
    case "success":
      // TypeScript knows response.data is User
      console.log(`Loaded user: ${response.data.name}`);
      break;
    case "error":
      // TypeScript knows response.message and response.code exist
      console.error(
        `Failed to load user: ${response.message} (${response.code})`
      );
      break;
    default:
      // Exhaustiveness checking
      const exhaustive: never = response;
      throw new Error(`Unhandled response type: ${exhaustive}`);
  }
}

This example demonstrates:

  • Discriminated unions for modeling different response states
  • Generic types for reusable components
  • Type guards through switch statements
  • Exhaustiveness checking with never
  • Real-world error handling with type safety

What You’ve Mastered (And What’s Coming)

You’re no longer just someone who adds types to JavaScript. You understand how to model complex business logic with unions, combine behaviors with intersections, and create type-safe abstractions that scale. You can write code that not only works but communicates its intent clearly to both humans and the compiler.

Most importantly, you’ve learned to think in types. You understand that good TypeScript isn’t about adding type annotations everywhere; it’s about modeling your domain accurately so that invalid states become unrepresentable.

In our final part, we’ll take everything you’ve learned and apply it to real-world scenarios:

  • React with TypeScript: Component props, hooks, and context that actually make sense
  • Node.js APIs: Express middleware, database layers, and configuration management
  • Testing strategies: Writing tests that leverage TypeScript’s type system
  • Advanced patterns: Builder patterns, state machines, and architectural considerations
  • Performance and tooling: Making your TypeScript build pipeline efficient

You’ve built the foundation. Now let’s construct something beautiful on top of it.

Ready to become the TypeScript developer who makes everyone else’s life easier? Part 4 is where theory meets practice, and where you stop being a student and start being the expert your team turns to when things get complicated.