πŸ›‘οΈ How to Build a Scalable QA Framework with Advanced TypeScript Patterns

Ivan DavidovIvan Davidov
8 min read

πŸ€– In our last article, we mastered asynchronicity, laying a robust foundation for our test framework. But a solid foundation is just the beginning. To build a truly scalable and maintainable automation suite, we need a strong architectural frame. That frame is an advanced, expressive type system.

This article is for the power user. We're moving beyond basic types to show you how to leverage TypeScript's advanced patterns to eliminate entire classes of bugs before you even run a single test.

You will learn five essential patterns for a world-class QA framework:

  • Enums: To manage fixed sets of constants and prevent typos.

  • Generics: To write highly reusable, type-safe code like an API client.

  • Zod & z.infer: To validate API responses at runtime and eliminate manual type definitions.

  • typeof: To create types directly from runtime objects.

  • Utility Types: To create flexible variations of existing types without duplicating code.

Mastering these concepts will transform your framework from a simple collection of tests into a scalable, self-documenting, and resilient asset.


βœ… Prerequisites

This guide builds upon the concepts from our previous article on the Page Object Model. You should be comfortable with the following:


πŸ€” The Problem: The Hidden Costs of a "Simple" Framework

When a framework is small, it's easy to keep it clean. But as it grows, "simple" solutions introduce hidden costs that make the codebase brittle and hard to maintain.

  • Magic Strings: Using raw strings like 'ADMIN' or 'POST' for roles or request methods is a ticking time bomb. A single typo ('ADMNIN') creates a bug that TypeScript can't catch.

  • Duplicated Logic: Writing a new fetch function for every API endpoint (fetchUser, fetchArticle, fetchComment) creates a maintenance nightmare. A change to authentication logic requires updating dozens of files.

  • Type Drift: You manually write a TypeScript interface for an API response. The API changesβ€”a field is renamed or removed. Your tests still compile, but they fail at runtime because your types lied.

  • Payload Confusion: You use the same massive Article type for both creating a new article and updating its title. This is confusing and inefficient.

These small issues compound, leading to a framework that is difficult and scary to refactor.


πŸ› οΈ The Solution, Part 1: Enums for Rock-Solid Constants

The fastest way to eliminate "magic string" bugs is with Enums. An enum is a restricted set of named constants.

The Brittle Way (Magic Strings)

Imagine a function that assigns a role. A typo in the string will pass silently through TypeScript.

// 🚨 THIS IS THE "BEFORE" - A BUG WAITING TO HAPPEN 🚨
function assignRole(username: string, role: string) {
    // What if someone passes 'admn' or 'editorr'?
    console.log(`Assigning role: ${role} to ${username}`);
}

// A simple typo means this code is logically flawed, but TS doesn't know.
assignRole('idavidov', 'admnin'); // Oops!

The Robust Fix (Enums)

By defining a UserRole enum, we force developers to choose from a valid list of options, giving us autocompletion and compile-time safety.

// βœ… THIS IS THE "AFTER" - TYPE-SAFE AND CLEAR βœ…
export enum UserRole {
    ADMIN = 'admin',
    EDITOR = 'editor',
    VIEWER = 'viewer',
}

function assignRole(username: string, role: UserRole) {
    console.log(`Assigning role: ${role} to ${username}`);
}

// 1. No typos: TS would throw an error if UserRole.ADMNIN existed.
// 2. Autocomplete: Your editor will suggest ADMIN, EDITOR, or VIEWER.
assignRole('idavidov', UserRole.ADMIN);

Rule of Thumb: If you have a fixed set of related strings, use an Enum.


πŸš€ The Solution, Part 2: Generics for Maximum Reusability

Generics are arguably the most powerful feature for writing scalable code. They allow you to write functions that can work with any type, without sacrificing type safety. The perfect use case is a reusable API request function.

The Repetitive Way (No Generics)

Without generics, you'd end up writing nearly identical functions for every API endpoint.

// 🚨 "BEFORE" - LOTS OF DUPLICATED CODE 🚨
async function fetchArticle(id: string): Promise<ArticleResponse> {
    const res = await request.get(`/api/articles/${id}`);
    return await res.json();
}

async function fetchUser(id: string): Promise<UserResponse> {
    const res = await request.get(`/api/users/${id}`);
    return await res.json();
}

The Scalable Fix (A Generic Function)

We can write one function, apiRequest, that can fetch any resource and return a strongly-typed response. The magic is the <T> placeholder.

// βœ… "AFTER" - A SINGLE, REUSABLE, TYPE-SAFE FUNCTION βœ…

// We define a function that accepts a generic type `T`.
// It returns a Promise whose body will be of type `T`.
async function apiRequest<T = unknown>({
    method,
    url,
}: // ... other params
ApiRequestParams): Promise<ApiRequestResponse<T>> {
    const response = await apiRequestOriginal({
        /* ... implementation details ... */
    });
    return {
        status: response.status,
        body: response.body as T, // We tell TS to trust us here
    };
}

When we call this function, we specify what T should be.

// T becomes ArticleResponse. The 'body' constant is now fully typed!
const { body } = await apiRequest<ArticleResponse>({
    method: 'GET',
    url: 'api/articles/my-article',
});

// We can now access body.article.title with full autocomplete
console.log(body.article.title);

πŸ›‘οΈ The Solution, Part 3: Zod & z.infer for End-to-End Safety

We've solved code duplication. Now let's solve "Type Drift." The ultimate source of truth for your data is the API itself. Zod is a TypeScript library which lets us create a schema that validates against the real API response at runtime, and z.infer lets us create a compile-time TypeScript type from that same schema.

One schema. Two benefits. Zero drift.

First, define a schema for your API response. This is an actual JavaScript object that describes the shape of your data.

// 1. Define the runtime schema with Zod
export const ArticleResponseSchema = z.object({
    article: z.object({
        slug: z.string(),
        title: z.string(),
        description: z.string(),
        body: z.string(),
        author: z.object({
            username: z.string(),
            // ... other author fields
        }),
    }),
});

Next, use the magic of z.infer to create a TypeScript type from the schema without any extra work.

// 2. Infer the TypeScript type directly from the schema
export type ArticleResponse = z.infer<typeof ArticleResponseSchema>;

// No need to write this manually!
// interface ArticleResponse {
//   article: {
//     slug: string;
//     title: string;
//     ...
//   }
// }

Now, in your test, you use both. The Zod schema validates the live data, and the inferred type gives you autocomplete and static analysis.

// 3. Use both in your test for 100% confidence
await test.step('Verify Create an Article', async () => {
    const { status, body } = await apiRequest<ArticleResponse>({
        /* ... request params ... */
    });

    // Runtime Check: Did the API response match our schema?
    // This will fail if the API changes, catching the bug immediately.
    expect(ArticleResponseSchema.parse(body)).toBeTruthy();

    // Compile-time Safety: We can now use 'body' with confidence.
    const articleId = body.article.slug;
    expect(status).toBe(201);
});


πŸ› οΈ The Solution, Part 4: typeof & Utility Types for Flexibility

Sometimes you need types for simple, ad-hoc objects, or you need to create slight variations of existing types for things like API update payloads.

typeof: Types from Runtime Objects

If you have a constant object in your code, you can use typeof to create a type that perfectly matches its shape.

// A runtime object defining a default payload
const defaultArticlePayload = {
    article: {
        title: 'My Default Title',
        description: 'A great article',
        body: 'The content...',
        tagList: ['testing', 'playwright'],
    },
};

// Create a type that exactly matches the object's shape
type ArticlePayload = typeof defaultArticlePayload;

// This function now only accepts objects with that exact shape
function createArticle(payload: ArticlePayload) {
    // ...
}

Partial & Pick: Types from Other Types

Using our ArticleResponse type from Zod, what if we want to update an article? We probably only need to send a few fields, not all of them. Utility Types let us create these variations on the fly.

  • Partial<T>: Makes all fields in T optional.

  • Pick<T, K>: Creates a new type by picking a few keys K from T.

// Our original type, where all fields are required
// type Article = { slug: string; title: string; body: string; ... }
type Article = z.infer<typeof ArticleResponseSchema>['article'];

// βœ… Scenario 1: An update payload where any field is optional.
// This creates a type like: { title?: string; body?: string; ... }
type UpdateArticlePayload = Partial<Article>;

// βœ… Scenario 2: A type representing just the unique identifier.
// This creates the type: { slug: string; }
type ArticleLocator = Pick<Article, 'slug'>;


πŸš€ Your Mission: Build an Unbreakable Framework

You are now equipped with the patterns used in the most robust, enterprise-grade test automation frameworks. Go back to your own project and look for opportunities to level up:

  1. Hunt for Magic Strings: Find any hardcoded strings ('admin', 'success', 'POST') and replace them with Enums.

  2. Schema-fy Your Endpoints: Choose your most critical API endpoint, write a Zod schema for it, and use z.infer to generate the type. Apply it in a test.

  3. Refactor with Generics: Identify two or more repetitive functions (like API calls) and refactor them into a single, reusable generic function.

  4. Create Smart Payloads: Look for POST or PATCH requests and use Utility Types like Partial or Pick to create precise, minimal payloads.

Adopting these advanced patterns is the final step in turning your test framework from a simple tool into a powerful, scalable, and truly dependable engineering asset.


πŸ™πŸ» 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.

0
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.