🏷️ A Practical Guide to TypeScript Custom Types for QA Automation

Ivan DavidovIvan Davidov
6 min read

🤖 In our last article, we mastered the logic that makes our tests "think": Functions, Loops, and Conditionals. We built a powerful engine. Now, it's time to build the chassis around it—a strong, protective frame that ensures everything fits perfectly.

This article is a deep dive into the core power of TypeScript for QA: creating custom, reusable types. This is how you make your test framework robust, self-documenting, and less prone to errors. We'll move beyond generic primitives like string to define the exact shape of your data.

By defining these data "contracts" you empower TypeScript to catch bugs for you before a test ever runs.


✅ Prerequisites

This article assumes you are comfortable with the topics from our previous discussions:

  • Basic TypeScript types (string, number, boolean)

  • Structuring data with Arrays and Objects

  • Writing reusable code with Functions

  • Automating actions with Loops (for, while)

  • Making decisions with Conditionals (if/else)

If you have these concepts down, you're ready to build a truly resilient test suite.


🚦 Union & String Literal Types: Be More Specific

A union type (|) lets you define a variable that can be one of several types. When you combine it with string literals (the exact string values), you create a highly precise type.

Instead of allowing any string for a parameter, you can specify the only strings that are valid. This is perfect for building safe API request functions.

Use Case: You have a function that sends an API request. The method can only be 'GET', 'POST', 'PUT', or 'DELETE'.

// Define a type that only allows these four specific strings
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

async function sendRequest(url: string, method: HttpMethod) {
    // ... your API call logic here
    console.log(`Sending a ${method} request to ${url}`);
}

// ✅ This works perfectly:
sendRequest('/api/users', 'POST');

// 🚨 TypeScript stops you right here! No runtime surprise.
// Argument of type '"FETCH"' is not assignable to parameter of type 'HttpMethod'.
sendRequest('/api/users', 'FETCH');

You've just eliminated a whole class of potential bugs caused by typos.


🏷️ Type Aliases: Naming Your Data Structures

A type alias gives a name to a type definition. While it can be used for any type (e.g., type UserID = string;), its real power in QA shines when defining the shape of an object.

Use Case: You need to create new users in your tests. You can define the exact structure of the data payload your API expects.

// Define the shape of the data needed to create a user
type UserPayload = {
    username: string;
    email: string;
    firstName: string;
    // Note: No 'id' here, as the server generates it!
};

// This function now requires a perfectly shaped object
async function createUser(payload: UserPayload) {
    return await api.post('/users', payload);
}

// ✅ You get full autocomplete and type-checking when building the payload
const newUserData: UserPayload = {
    username: 'test-qa',
    email: 'qa@test.com',
    firstName: 'Taylor',
};

await createUser(newUserData);

Your createUser function is now self-documenting. Anyone using it knows exactly what data to provide.


📝 Interfaces: Defining Object "Contracts"

An interface is another way to define the shape of an object. It's very similar to a type alias, but with a few key differences that make it powerful for specific QA scenarios. Think of it as defining a "contract" that an object must adhere to.

Use Case: Your API responds with a user object that has more fields than the payload you sent, like a server-generated id and createdAt timestamp.

// Define the contract for a user object returned by the API
interface UserResponse {
    id: string;
    username: string;
    email: string;
    createdAt: Date;
}

// A function that is expected to return data matching this interface
async function fetchUser(userId: string): Promise<UserResponse> {
    const response = await api.get(`/users/${userId}`);
    return response.body; // We'll make this safer in a moment!
}


🆚 Type Alias vs. Interface: The QA Guideline

This is a common point of confusion. For QA automation, the guideline is simple:

  • Use a type alias when defining data shapes for API payloads or any union types. It's flexible and straightforward.

  • Use an interface when you need to extend another object's contract. This is incredibly useful for creating consistent API response models or for building Page Object Models.

The ability to be "extended" is the superpower of interfaces.

Extending Interfaces in Action

Imagine all your API responses have a common wrapper. You can define a BaseApiResponse and have specific responses extend it. This is DRY (Don't Repeat Yourself) applied to your types.

// The base contract for ALL our API responses
interface BaseApiResponse {
    success: boolean;
    requestId: string;
}

// The user response contract EXTENDS the base, inheriting its properties
interface UserDataResponse extends BaseApiResponse {
    data: {
        id: string;
        username: string;
    };
}

// Now, an object of type UserDataResponse MUST have `success`, `requestId`, and `data`.

☝️ Type Assertions: Taking Control

When you get data from an external source (like an API response body), TypeScript doesn't know its shape and often labels it as any. Type assertions are how you tell the compiler, "Trust me, I know what this is."

The as Keyword

Use as to cast data to the interface or type you defined.

Use Case: You've called fetchUser and you are confident the API will return a UserResponse.

const response = await api.get('/users/123');

// Tell TypeScript: "Treat this response body as a UserResponse object"
const user = response.body as UserResponse;

// ✅ Now you get type safety and autocomplete!
console.log(user.id);
console.log(user.username);

// 🚨 TypeScript would show an error if you tried this:
// Property 'password' does not exist on type 'UserResponse'.
// console.log(user.password);

The ! Non-Null Assertion

Sometimes TypeScript is worried a value could be null or undefined, even if you know it won't be. The ! operator tells the compiler, "I guarantee this value exists."

Use Case: Accessing an environment variable that is required for your tests to run.

// TypeScript warns: 'process.env.API_URL' is possibly 'undefined'.
// const baseUrl = process.env.API_URL;

// Using '!', you tell TypeScript: "I'm sure this is set."
const baseUrl = process.env.API_URL!;

await api.get(`${baseUrl}/health`);

⚠️ A Critical Warning: Type assertions (as and !) turn off TypeScript's safety features. You are making a promise to the compiler. If you are wrong (if the API response shape changes or the environment variable is missing) you will get a runtime error. Use them only when you are absolutely certain about the data's shape or existence.


🚀 Your Next Step: Build a Type-Safe Flow

You've learned how to create a safety net for your test data. Custom types prevent bugs, make your code easier to read, and speed up development with better autocomplete.

Your Mission:

  1. Define a Payload: Create a type alias named ProductPayload for a new product. It should have name: string and price: number.

  2. Define a Response: Create an interface named ProductResponse that extends your ProductPayload type (yes, interfaces can extend types!) and adds id: string and inStock: boolean.

  3. Mock a Function: Write a const mockFetchProduct = () function that returns a plain JavaScript object matching the ProductResponse structure.

  4. Assert the Type: Call your mock function and use the as keyword to cast the result to your ProductResponse interface.

  5. Log the Proof: console.log() the id and name from your typed variable to prove it works!

Take on this challenge. You'll immediately see how these tools work together to create a robust, type-safe automation framework.

1
Subscribe to my newsletter

Read articles from Ivan Davidov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ivan Davidov
Ivan Davidov

Automation QA Engineer, ISTQB CTFL, PSM I, helping teams improve the quality of the product they deliver to their customers. • Led the development of end-to-end (E2E) and API testing frameworks from scratch using Playwright and TypeScript, ensuring robust and scalable test automation solutions. • Integrated automated tests into CI/CD pipelines to enhance continuous integration and delivery. • Created comprehensive test strategies and plans to improve test coverage and effectiveness. • Designed performance testing frameworks using k6 to optimize system scalability and reliability. • Provided accurate project estimations for QA activities, aiding effective project planning. • Worked with development and product teams to align testing efforts with business and technical requirements. • Improved QA processes, tools, and methodologies for increased testing efficiency. • Domain experience: banking, pharmaceutical and civil engineering. Bringing over 3 year of experience in Software Engineering, 7 years of experience in Civil engineering project management, team leadership and project design, to the table, I champion a disciplined, results-driven approach, boasting a record of more than 35 successful projects.