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


π€ 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:
TypeScript Fundamentals:
A Playwright Project: You should have a basic understanding of how to write and run a test.
You understand the purpose of the Page Object Model (
Class
).You understand and use
async/await
,Promise.all
, andtry/catch
correctly.
π€ 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:
Hunt for Magic Strings: Find any hardcoded strings (
'admin'
,'success'
,'POST'
) and replace them withEnums
.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.Refactor with Generics: Identify two or more repetitive functions (like API calls) and refactor them into a single, reusable generic function.
Create Smart Payloads: Look for
POST
orPATCH
requests and useUtility Types
likePartial
orPick
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.
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.