DEV Community

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

Posted on • Edited on • Originally published at idavidov.eu

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

πŸ€– 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');
Enter fullscreen mode Exit fullscreen mode

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

Allowed Methods


🏷️ 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: '[email protected]',
    firstName: 'Taylor',
};

await createUser(newUserData);
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

Interface


πŸ†š 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`.
Enter fullscreen mode Exit fullscreen mode

☝️ 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);
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

⚠️ 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.

Type Assertions


πŸš€ 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.


πŸ™πŸ» Thank you for reading! Building robust, scalable automation frameworks is a journey best taken together. If you found this article helpful, consider joining a growing community of QA professionals πŸš€ who are passionate about mastering modern testing.

Join the community and get the latest articles and tips by signing up for the newsletter.

Top comments (4)

Collapse
 
nevodavid profile image
Nevo David

love seeing this broken down like that - makes me wanna mess with my test setup asap. you think most bugs show up from missing types or more from logic mistakes?

Collapse
 
idavidov13 profile image
idavidov13

Can't be happier πŸ™πŸ»
The goal of implementation of custom types is:

  1. To prevent bugs (failing tests) from misused parameters, and
  2. To document you code, so whenever you use any variable from given type, intellisense will give you all the possibilities, so there will be no need to jump from IDE to the documentation itself.
  3. A minor spoiler - in near future, we will cover a topic, which builds on top of this one - we will use Zod (schema validation TypeScript library) to verify every single response from an API request with little to no additional work. Please, let me know what is the result of messing your test setup 😎
Collapse
 
quangto profile image
QuangTo

good article. thanks mate

Collapse
 
idavidov13 profile image
idavidov13

Thank you, for the kind words πŸ™πŸ»