Skip to lesson

learningtypescript.org / intermediate / 23-type-safe-apis · lesson 23 of 25

TL;DR

Build type-safe APIs in TypeScript with generic fetch wrappers, discriminated union responses, and runtime type guards.

Key concepts

  • type-safe API TypeScript
  • typed fetch wrapper
  • API response types
  • TypeScript API patterns

Type-Safe APIs

Every web application eventually talks to an API. The problem is that network responses arrive as raw JSON — untyped blobs that TypeScript knows nothing about. Without discipline, you end up casting everything to any, losing all the benefits of the type system exactly where bugs are most likely to appear.

Type-safe APIs are about drawing a clear boundary between the untyped world of network I/O and the typed world of your application logic. You define what a response should look like, validate it at the boundary, and let TypeScript enforce correctness everywhere else. When a field is renamed in the backend or a nullable value appears unexpectedly, you catch it immediately — not in a 3am production incident.

This lesson covers the core patterns: typing response shapes, building a generic fetch wrapper, modeling success and failure with discriminated unions, and writing runtime type guards to validate data you cannot fully trust.

Defining Response Shapes

The first step is replacing any with an interface that describes exactly what the API returns. This is not just documentation — TypeScript uses these shapes to flag every incorrect property access across your entire codebase.

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
}

interface Post {
  id: number;
  title: string;
  authorId: number;
  publishedAt: string | null;
}

// Simulate an API response arriving as parsed JSON
const rawUserResponse: unknown = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  role: "admin",
};

// Cast only at the boundary — from here on, TypeScript knows the shape
const user = rawUserResponse as User;

console.log(`User: ${user.name} (${user.role})`);
// TypeScript error if you try: user.password — property doesn't exist
console.log(`Email: ${user.email}`);

Notice that the cast from unknown to User happens exactly once, at the boundary where data enters the system. After that, every access to user.name, user.role, or any other property is checked by the compiler.

A Generic Fetch Wrapper

Writing a typed cast in every single API call is repetitive and easy to skip. The better approach is a generic wrapper that handles the fetch, parses the JSON, and returns a typed result — so callers never have to think about unknown again.

// Simulated async fetch — in a real app this would call fetch()
async function simulateFetch<T>(data: T, shouldFail = false): Promise<T> {
  await new Promise((resolve) => setTimeout(resolve, 10));
  if (shouldFail) throw new Error("Network request failed");
  return data;
}

async function apiGet<T>(simulatedData: T): Promise<T> {
  // In production: const response = await fetch(url)
  //                const data = await response.json()
  const data = await simulateFetch(simulatedData);
  return data as T;
}

interface Product {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
}

async function main() {
  const product = await apiGet<Product>({
    id: 42,
    name: "Mechanical Keyboard",
    price: 149.99,
    inStock: true,
  });

  // TypeScript knows product.price is a number
  console.log(`${product.name}: $${product.price.toFixed(2)}`);
  console.log(`In stock: ${product.inStock ? "Yes" : "No"}`);
}

main();

The generic <T> parameter travels from the call site all the way through the wrapper. When you call apiGet<Product>(...), TypeScript infers that the return type is Promise<Product> — giving you full autocompletion and error checking on everything you do with the result.

Modeling Success and Failure

Real APIs fail. A common mistake is throwing exceptions for API errors and leaving callers to catch them or not. A more explicit approach is a discriminated union that forces every caller to handle both cases.

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

interface Article {
  id: number;
  title: string;
  body: string;
}

// Returns a result instead of throwing
async function fetchArticle(id: number): Promise<ApiResult<Article>> {
  // Simulate different outcomes based on id
  if (id <= 0) {
    return { success: false, error: "Invalid article ID", status: 400 };
  }
  if (id > 100) {
    return { success: false, error: "Article not found", status: 404 };
  }

  return {
    success: true,
    data: {
      id,
      title: `Article ${id}: TypeScript Deep Dive`,
      body: "TypeScript brings static types to JavaScript...",
    },
  };
}

async function main() {
  const results = await Promise.all([
    fetchArticle(1),
    fetchArticle(-5),
    fetchArticle(999),
  ]);

  for (const result of results) {
    if (result.success) {
      // TypeScript knows result.data is Article here
      console.log(`Found: "${result.title}" — ${result.data.title}`);
    } else {
      // TypeScript knows result.error and result.status here
      console.log(`Error ${result.status}: ${result.error}`);
    }
  }
}

main();

The discriminant property success lets TypeScript narrow the type in each branch. In the if (result.success) branch, result.data is guaranteed to exist. In the else branch, result.error and result.status are available. You cannot accidentally access result.data in the error branch — the compiler prevents it.

Runtime Type Guards

Interfaces and generics only exist at compile time. If your API returns something unexpected — a missing field, a wrong type, a null where you expected a string — TypeScript cannot catch that at runtime. Type guards bridge the gap.

A type guard is a function that returns value is T, which tells TypeScript: "if this function returns true, narrow the type to T in subsequent code."

interface Order {
  id: number;
  customerId: number;
  items: string[];
  total: number;
  status: "pending" | "shipped" | "delivered";
}

function isOrder(value: unknown): value is Order {
  if (typeof value !== "object" || value === null) return false;
  const obj = value as Record<string, unknown>;

  return (
    typeof obj.id === "number" &&
    typeof obj.customerId === "number" &&
    Array.isArray(obj.items) &&
    obj.items.every((item) => typeof item === "string") &&
    typeof obj.total === "number" &&
    (obj.status === "pending" ||
      obj.status === "shipped" ||
      obj.status === "delivered")
  );
}

// Simulate three different payloads arriving from an API
const payloads: unknown[] = [
  { id: 1, customerId: 42, items: ["Widget", "Gadget"], total: 59.99, status: "shipped" },
  { id: 2, customerId: 7, items: ["Thing"], total: 12.0 }, // missing status
  { id: "bad", customerId: null, items: [], total: 0, status: "pending" }, // wrong types
];

for (const payload of payloads) {
  if (isOrder(payload)) {
    console.log(`Order #${payload.id}${payload.status} — $${payload.total}`);
  } else {
    console.log("Rejected: payload does not match Order shape");
  }
}

Type guards work best when kept close to the API boundary. Validate once as data enters the system, then let the rest of your code work with fully typed values without any defensive checks.

Try It Yourself

Build a small typed API client that fetches a list of users, filters by role, and returns a formatted summary. Handle both success and failure using a discriminated union, and validate the incoming data with a type guard.

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

interface User {
  id: number;
  name: string;
  role: "admin" | "member" | "guest";
  active: boolean;
}

function isUser(v: unknown): v is User {
  if (typeof v !== "object" || v === null) return false;
  const o = v as Record<string, unknown>;
  return (
    typeof o.id === "number" &&
    typeof o.name === "string" &&
    (o.role === "admin" || o.role === "member" || o.role === "guest") &&
    typeof o.active === "boolean"
  );
}

async function fetchUsers(): Promise<ApiResult<User[]>> {
  // Simulated response — swap this for a real fetch call
  const raw: unknown[] = [
    { id: 1, name: "Alice", role: "admin", active: true },
    { id: 2, name: "Bob", role: "member", active: false },
    { id: 3, name: "Carol", role: "guest", active: true },
    { id: 4, name: "Dave", role: "member", active: true },
    { id: 5, name: "Bad Record", role: "unknown" }, // invalid
  ];

  const valid = raw.filter(isUser);

  if (valid.length === 0) {
    return { success: false, error: "No valid users in response" };
  }

  return { success: true, data: valid };
}

function summarize(users: User[], role: User["role"]): string {
  const filtered = users.filter((u) => u.role === role && u.active);
  if (filtered.length === 0) return `No active ${role}s found`;
  return `Active ${role}s: ${filtered.map((u) => u.name).join(", ")}`;
}

async function main() {
  const result = await fetchUsers();

  if (!result.success) {
    console.log(`Failed: ${result.error}`);
    return;
  }

  console.log(`Loaded ${result.data.length} valid users`);
  console.log(summarize(result.data, "admin"));
  console.log(summarize(result.data, "member"));
  console.log(summarize(result.data, "guest"));
}

main();

Try extending this example: add a fetchUserById function that returns ApiResult<User>, or add a new role like "moderator" and observe how TypeScript flags every place that needs updating.

Key Takeaways

  • Cast unknown at the boundary — accept unknown from the network and cast to a typed interface exactly once, so all downstream code is type-safe
  • Generic wrappers eliminate repetition — a single apiGet<T> function gives every call site a typed return without duplicating cast logic
  • Discriminated unions make errors explicitApiResult<T> forces callers to handle failure; forgetting to check result.success is a compile-time error
  • Type guards validate at runtime — compile-time types disappear at runtime, so validate incoming data with value is T guard functions before trusting it
  • Narrow once, use everywhere — validate and narrow at the API layer; pass fully typed values to the rest of your application so business logic stays clean

Pro Tip: Libraries like Zod let you define a schema that simultaneously serves as a TypeScript type and a runtime validator — so you write the shape once and get both isUser-style validation and full type inference for free. It's worth adopting on any project where API contracts matter.

Next Steps

You've been writing TypeScript code throughout this course, but you haven't yet explored the file that controls how all of it gets checked and compiled. Next, you'll learn how tsconfig.json works — strict mode, null safety, compiler targets, and the essential options every project needs.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.