TypeScript Tutorial: Writing Async and DOM Logic That Actually Works
9 mins read

TypeScript Tutorial: Writing Async and DOM Logic That Actually Works

The Problem with Toy Examples

I’m so tired of “Hello World” TypeScript tutorials. You probably know the ones I’m talking about. They show you how to define a User interface with a string and a number, maybe add two integers together, and call it a day.

Real code isn’t like that. Last Tuesday, I was digging through a UI test automation suite written by a well-meaning contractor. It was technically TypeScript, but every API response, DOM element, and async payload was typed as any. It defeated the entire purpose of the language. The pipeline was failing randomly, and it took me two hours to realize a login function was trying to call .click() on a generic object instead of an actual HTML element.

So let’s build something practical. If you want to actually use TypeScript for frontend logic or UI automation, you need to understand how functions, async API calls, and the DOM interact without resorting to the any escape hatch.

Functions That Don’t Lie to You

Let’s start with functions. The biggest mistake I see isn’t missing types; it’s overly permissive types. When you’re handling user input or interacting with a page, you want strict boundaries.

Here’s a standard utility function for validating a login attempt. Notice how we use a union type for the return value instead of throwing a generic error.

type LoginStatus = 
  | { success: true; token: string }
  | { success: false; reason: string };

function validateCredentials(email: string, pass: string): LoginStatus {
  if (!email.includes('@')) {
    return { success: false, reason: "Invalid email format" };
  }
  
  if (pass.length < 8) {
    return { success: false, reason: "Password too short" };
  }

  // Pretend we did some local hashing or validation here
  return { success: true, token: "temp_local_token_8923" };
}

I prefer returning discriminated unions like this over throwing errors. Try/catch blocks in JavaScript are messy and lose type safety. By returning a specific object shape, TypeScript forces whoever calls validateCredentials to check the success property before trying to access the token. It’s a tiny pattern that prevents massive headaches later.

The Async API Trap

TypeScript programming - TypeScript Programming Example - GeeksforGeeks
TypeScript programming – TypeScript Programming Example – GeeksforGeeks

Fetching data is where most developers give up and let any sneak into their codebase. The native fetch API is a trap. When you call response.json(), TypeScript types the result as any. It just assumes you know what you’re doing.

You don’t. I don’t either. APIs change without warning.

Here is how you handle an async API call safely. We’re going to fetch a user session, but we’ll use a type guard to prove the data is what we think it is.

interface SessionData {
  userId: number;
  role: 'admin' | 'user';
  lastActive: string;
}

// Type guard to validate the API response at runtime
function isSessionData(data: unknown): data is SessionData {
  return (
    typeof data === 'object' &&
    data !== null &&
    'userId' in data &&
    'role' in data &&
    (data as SessionData).role === 'admin' || (data as SessionData).role === 'user'
  );
}

async function fetchUserSession(apiEndpoint: string): Promise<SessionData | null> {
  try {
    const response = await fetch(apiEndpoint);
    
    if (!response.ok) {
      console.error(HTTP error! status: ${response.status});
      return null;
    }

    const rawData: unknown = await response.json();

    if (isSessionData(rawData)) {
      return rawData; // TypeScript now knows this is SessionData
    }
    
    console.error("API response didn't match expected schema");
    return null;

  } catch (error) {
    console.error("Network failure", error);
    return null;
  }
}

By forcing the .json() result into an unknown type, we strip away the dangerous any. You literally cannot use rawData until you pass it through the isSessionData check. Yes, it’s more boilerplate. But I’d rather write five extra lines of validation than debug a silent failure in production because the backend team renamed userId to user_id.

Wrestling with the DOM

If you’re writing vanilla frontend code or building a Page Object Model for testing, you have to interact with the DOM. TypeScript is notoriously annoying about DOM elements because it doesn’t know what your HTML looks like.

If you run document.querySelector('.login-btn'), TypeScript types it as Element | null. You can’t click an Element. You can’t read the value of an Element.

Here’s the pattern I use to bind events safely.

function setupLoginForm() {
  // Narrowing the type with a cast, but checking for null first
  const emailInput = document.querySelector<HTMLInputElement>('#email');
  const passInput = document.querySelector<HTMLInputElement>('#password');
  const submitBtn = document.querySelector<HTMLButtonElement>('#submit-login');

  // The dreaded null check. Don't skip this.
  if (!emailInput || !passInput || !submitBtn) {
    console.warn("Login form elements missing from DOM. Aborting setup.");
    return;
  }

  submitBtn.addEventListener('click', async (e: MouseEvent) => {
    e.preventDefault();
    
    // Because we typed them as HTMLInputElement, .value is perfectly safe
    const email = emailInput.value;
    const pass = passInput.value;

    submitBtn.disabled = true;
    submitBtn.textContent = "Loading...";

    const status = validateCredentials(email, pass);
    
    if (!status.success) {
      alert(status.reason);
      submitBtn.disabled = false;
      submitBtn.textContent = "Login";
      return;
    }

    const session = await fetchUserSession('/api/v1/session');
    console.log("Logged in!", session);
  });
}

Notice the <HTMLInputElement> generic passed to querySelector. As of TypeScript 5.7.2, this is the cleanest way to tell the compiler exactly what kind of element you’re expecting. Just remember: TypeScript only enforces this at compile time. If that selector actually points to a <div>, your code will still blow up in the browser.

TypeScript programming - Programming language TypeScript: advantages, and disadvantages
TypeScript programming – Programming language TypeScript: advantages, and disadvantages

Putting It Together: The Page Object Model

Why am I showing you all this? Because these pieces—strict functions, safe async APIs, and typed DOM interactions—are exactly what you need to write maintainable UI automation.

If you use tools like WebdriverIO or Playwright, you’ll inevitably write a Page Object Model (POM). A POM is just a class that encapsulates the DOM selectors and actions for a specific page. Here’s what a clean, fully-typed login page object looks like when we apply everything we just covered.

// Mocking a generic browser automation API for the example
interface BrowserElement {
  setValue: (text: string) => Promise<void>;
  click: () => Promise<void>;
  getText: () => Promise<string>;
  isDisplayed: () => Promise<boolean>;
}

class LoginPage {
  // Private getters keep selectors encapsulated
  private get emailField(): Promise<BrowserElement> {
    return this.findElement('#email');
  }

  private get passwordField(): Promise<BrowserElement> {
    return this.findElement('#password');
  }

  private get submitButton(): Promise<BrowserElement> {
    return this.findElement('#submit-login');
  }

  private get errorMessage(): Promise<BrowserElement> {
    return this.findElement('.error-toast');
  }

  // Mock method to simulate finding an element in a test framework
  private async findElement(selector: string): Promise<BrowserElement> {
    // In real WebdriverIO, this would be await $(selector)
    return {} as BrowserElement; 
  }

  /**
   * Executes the full login workflow
   */
  public async login(email: string, pass: string): Promise<void> {
    const emailEl = await this.emailField;
    const passEl = await this.passwordField;
    const btnEl = await this.submitButton;

    await emailEl.setValue(email);
    await passEl.setValue(pass);
    await btnEl.click();
  }

  /**
   * Checks if the error toast is visible and returns its text
   */
  public async getLoginError(): Promise<string | null> {
    const errorEl = await this.errorMessage;
    
    if (await errorEl.isDisplayed()) {
      return await errorEl.getText();
    }
    
    return null;
  }
}

This class is completely self-contained. The test file that calls it doesn’t need to know anything about CSS selectors or DOM types. It just calls await loginPage.login('test@test.com', 'password123').

A Quick Reality Check on Build Times

software test automation - Software Test Automation. What is Software Testing | by ...
software test automation – Software Test Automation. What is Software Testing | by …

I want to drop one final piece of advice that tutorials usually skip. When you start building large POM structures and heavily typing your async responses, the TypeScript compiler (tsc) gets slow.

Running a massive test suite on Node.js 22.14.0 last month, I noticed our pre-test compilation was taking forever. I benchmarked it. We were spending 4 minutes and 12 seconds just compiling the TypeScript test files before a single browser even opened.

I swapped out tsc for esbuild in our test runner config. It cut the build time down to 45 seconds.

The gotcha? esbuild strips types but doesn’t actually type-check them. It just compiles. So you have to run tsc --noEmit in a separate parallel CI step to catch the actual type errors. It’s a slightly messier workflow, but saving three and a half minutes on every single PR run is absolutely worth the trade-off.

Stop settling for tutorials that treat TypeScript like a fancy linter for basic variables. Force your API calls into known shapes. Narrow your DOM elements. It takes more typing upfront, but you’ll spend way less time staring at “undefined is not an object” in your console.

Leave a Reply

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