TypeScript Functions: Stop Guessing Your API Responses

Well, that’s not entirely accurate — I actually spent three hours last Tuesday debugging a frontend crash that really shouldn’t have happened. The error? Cannot read properties of undefined (reading 'data'). You know the one — it’s the classic JavaScript greeting card.

The culprit wasn’t some complex logic failure. It was a function I wrote six months ago that implicitly returned any because I was too lazy to type the response. TypeScript silently let it slide, I shipped it, and it worked — until the backend team changed the payload structure for the user profile endpoint.

And if I had strictly typed that function, the build would have failed the moment I updated the API client. Instead, production broke.

Functions are the atomic unit of your application’s logic. In TypeScript, they are also your first line of defense against chaos. Let’s look at how to write functions that actually protect you, specifically focusing on async data fetching and clean architecture. I’m writing this using TypeScript 5.8.2, so if you’re on an older version, your mileage might vary slightly.

The Explicit Return Trap

TypeScript is smart — scarily smart. Its type inference engine can look at your code and figure out what a function returns 90% of the time. But relying on inference for function return types is a bad habit I broke years ago.

Why? Because inference locks in implementation details as the contract. If you accidentally change the implementation to return a string instead of a number, TypeScript says, “Okay, this function now returns a string.” It doesn’t warn you that you broke the contract expected by the rest of your app.

Here is the pattern I enforce in my codebases now:

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

// ❌ Bad: Implicit return type. 
// If I change this to return null later, TS just shrugs and updates the inference.
const getUserBad = (id: number) => {
  return { id, username: "dev_user", isActive: true };
};

// ✅ Good: Explicit return type.
// If I mess up the implementation, TS yells at me right here.
const getUserGood = (id: number): User => {
  return { id, username: "dev_user", isActive: true };
};

It feels like extra typing (pun intended), but it anchors your logic. You define the “what” before the “how.”

TypeScript logo - Typescript Logo PNG Vector (SVG) Free Download
TypeScript logo – Typescript Logo PNG Vector (SVG) Free Download

Async Functions and the Generic Wrapper

Most of the functions we write these days are async. We’re fetching data, hitting APIs, or waiting for a database. This is where TypeScript shines, but also where it gets messy if you aren’t careful with Generics.

I used to write a fetch wrapper for every single endpoint. It was tedious. Then I realized I could just write one strictly typed generic function to handle the heavy lifting. This is the exact pattern I use in my current Node 24.x projects.

// The generic wrapper
async function apiRequest<T>(url: string, config?: RequestInit): Promise<T> {
  const response = await fetch(url, config);
  
  if (!response.ok) {
    throw new Error(API Error: ${response.status});
  }

  // We cast the json() return to T because fetch doesn't know our schema
  return response.json() as Promise<T>;
}

// Usage
interface Product {
  sku: string;
  price: number;
}

// This function is now fully typed without extra boilerplate
async function getProduct(sku: string): Promise<Product> {
  return apiRequest<Product>(/api/products/${sku});
}

// If you try to access a property that doesn't exist, TS catches it
async function main() {
  const product = await getProduct("XYZ-123");
  console.log(product.price.toFixed(2)); 
  // console.log(product.name); // Error: Property 'name' does not exist on type 'Product'
}

Notice the Promise<T> return type? That’s critical. Async functions always return a Promise. If you forget that wrapper, you’ll try to access properties on the Promise itself rather than the resolved data.

The “Config Object” Pattern

Positional arguments are the worst. I once debugged a function called createUser(name, email, isAdmin, isActive, age) where someone passed true for isActive in the isAdmin slot. Suddenly, regular users had admin rights. Disaster.

For any function with more than two arguments, I use a single object argument. It mimics named parameters (like in Python) and makes the call site readable.

type CreateUserParams = {
  username: string;
  email: string;
  role?: 'admin' | 'user' | 'guest'; // Union types for safety
  metadata?: Record<string, string>;
};

function createUser({ 
  username, 
  email, 
  role = 'user' // Default value handling
}: CreateUserParams): void {
  console.log(Creating ${role} with email ${email});
}

// Readable and order-independent
createUser({
  email: "alex@example.com",
  username: "alexcode2026",
  role: "admin"
});

This pattern is particularly useful when refactoring. You can add new optional properties to the CreateUserParams type without breaking every single place the function is called. It’s backward compatibility for free.

DOM Interaction and Event Handlers

Let’s talk about the DOM. If you’re working with vanilla JS or even React refs, typing events can be a nightmare of any if you aren’t specific. The generic Event type is usually too broad — it doesn’t know about value on an input or key on a keyboard event.

TypeScript logo - Using TypeScript to Create a Better Developer Experience | by Nick ...
TypeScript logo – Using TypeScript to Create a Better Developer Experience | by Nick …

Here is how I handle form inputs without losing my mind:

function setupSearchInput(selector: string) {
  const input = document.querySelector<HTMLInputElement>(selector);

  if (!input) {
    console.warn(Element ${selector} not found);
    return;
  }

  // Explicitly typing the event gives us access to 'e.key' and 'e.target.value'
  const handleEnter = (e: KeyboardEvent) => {
    if (e.key === 'Enter') {
      // TS knows this is an input element because of the querySelector generic
      console.log(Searching for: ${input.value}); 
    }
  };

  input.addEventListener('keydown', handleEnter);
}

Using document.querySelector<HTMLInputElement> is the secret sauce here. Without it, TypeScript treats input as a generic Element, which doesn’t have a .value property. You’d have to cast it every time you used it. Do the casting once at the selection step, and the rest of your function stays clean.

Overloads: When One Signature Isn’t Enough

Sometimes a function needs to behave differently based on what you pass it. I ran into this recently when building a utility that could either format a date or return a raw timestamp depending on a flag.

Union types in the return (string | number) are annoying because you have to check the type every time you use the result. Function overloads solve this elegantly.

computer code on monitor - Amazon.com: Large Canvas Wall Art Programming code on computer ...
computer code on monitor – Amazon.com: Large Canvas Wall Art Programming code on computer …
// Signature 1: Pass true, get a number
function getTimestamp(date: Date, asEpoch: true): number;

// Signature 2: Pass false (or nothing), get a string
function getTimestamp(date: Date, asEpoch?: false): string;

// Implementation (handles all cases)
function getTimestamp(date: Date, asEpoch?: boolean): number | string {
  if (asEpoch) {
    return date.getTime();
  }
  return date.toISOString();
}

// Usage
const epoch = getTimestamp(new Date(), true); // Type: number
const iso = getTimestamp(new Date());         // Type: string

// No type guards needed!
console.log(epoch.toFixed(0)); 
console.log(iso.split('T'));

This feature is underused. It allows you to write polymorphic functions that are still strictly typed for the consumer. It’s a bit more code to write the signatures, but the developer experience (DX) for anyone using your function is vastly better.

Why This Matters

I used to think “getting it working” was the goal. Now I realize that “keeping it working” is the actual job. TypeScript functions act as a contract between different parts of your system. When you use generics, strict return types, and proper argument objects, you aren’t just writing code; you’re writing documentation that the compiler enforces.

And if you find yourself fighting the type system, stepping back and defining your function signatures first usually solves the problem. It forces you to think about the data flow before you get bogged down in the loop logic.

So, stop using any. Your future self, debugging a production outage at 11 PM on a Tuesday, will thank you.

Zahra Al-Farsi

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *