You've built the framework. This lesson is the audit — confirming each TypeScript skill is actually load-bearing in your code, not decorative — and the optional extensions that turn a competent framework into one you'd be proud to show in an interview. The self-assessment is honest if you actually answer the questions, so resist the urge to skim.
Self-assessment checklist
Open your project. Walk this list one item at a time. For each, find the specific file and line in your codebase that proves the answer.
Type definitions and utility types
- Every entity (
User,Product,CartItem,Order) is defined as an interface insrc/types/. - At least one
Omit<T, ...>and onePartial<T>derivative exists (e.g.CreateUserInput,UpdateUserInput). - A generic
ApiResponse<T>interface exists and is used by API helpers. - No
anyappears insrc/types/— every property has a precise type.
Page objects
- Each page extends a
BasePage(or composes a shared helper) for navigation utilities. - Every locator field is
private readonlyand assigned in the constructor. - Every public method has an explicit return type —
Promise<void>,Promise<number>,Promise<string>, etc. No inferred returns on the public API. - No raw selector string appears in any file under
tests/. Grep fordata-testidandgetByTestIdintests/to confirm.
Factories
- A single
createFactory<T>generic powers every entity factory. - Every factory uses
Partial<T>for overrides. - A
createMany<T>helper exists and is used by at least one test that needs bulk data. - All defaults are spread first, overrides spread last — verify by reading each factory's body.
API helpers
-
apiRequest<T>is generic over the response data shape. - Endpoint-specific functions (
login,createUser, etc.) returnPromise<T>, notPromise<ApiResponse<unknown>>. - Errors and successes are distinguished — either via discriminated union or via thrown errors.
Configuration
-
Environmentis a literal union, not a string. -
getBaseUrl(env)is exhaustive — adding"qa"to the union surfaces a compile error in the lookup table. - Browser and timeout types live in
src/config/and are imported by tests rather than re-declared.
Strict mode
-
tsconfig.jsonhas"strict": true. -
tsc --noEmitpasses with zero errors. - No
// @ts-ignore,// @ts-expect-error, oras anyin your source.
If you can tick every box, the framework is genuinely type-safe — not just typed. If a few boxes are unticked, fix them before moving on. The stretch goals below assume the core framework is solid.
Reflection questions
Answer these in your head (or in a notes file). They're how you find out whether you understood the chapters or merely followed them.
- Where does
Partial<T>save the most code? Find the call site that benefits most. Imagine the same call withoutPartial<T>— how many extra lines per test? - What changes if
User.rolebecomes"admin" | "tester" | "viewer" | "owner"? Trace the compile errors. How many places does the change propagate to automatically? - Where would
interface declaration mergingadd value? Hint: if you targeted Cypress, you used it forChainable. If Playwright, where could it apply — error matchers, fixtures? - What's the trade-off of
enumvs literal union forEnvironment? Re-read chapter 6's enum lesson. Could you switch and what would change? - Which one piece of the framework do you trust the least? That's the one to refactor first. The honest answer here is the highest-leverage signal you'll get from this whole project.
Stretch goal 1 — a fluent builder API for test data
Most factories take a single overrides object. A fluent builder exposes the same overrides as chainable methods, which makes intent visible in tests:
class UserBuilder {
private user: Partial<User> = {};
static create(): UserBuilder {
return new UserBuilder();
}
withRole(role: User["role"]): this {
this.user.role = role;
return this;
}
withEmail(email: string): this {
this.user.email = email;
return this;
}
build(): User {
return createUser(this.user);
}
}
const admin = UserBuilder.create().withRole("admin").withEmail("a@x.com").build();Two TypeScript points worth noticing. First, every with... method returns this — the type, not the value — so subclasses extending UserBuilder would chain with their own methods preserved. Second, User["role"] is an indexed access type — it pulls the role union out of the User interface so adding a new role automatically becomes available in the builder.
The fluent style isn't always better than createUser({ role: "admin" }). The win shows up when there are five or six relevant overrides — at that point, named methods read like documentation. Add a UserBuilder to your framework and decide for yourself when you'd reach for which.
Stretch goal 2 — custom type guards for API responses
A discriminated union for API responses, plus a custom guard:
type ApiResult<T> =
| { ok: true; status: number; data: T }
| { ok: false; status: number; error: string; code: string };
function isApiSuccess<T>(res: ApiResult<T>): res is Extract<ApiResult<T>, { ok: true }> {
return res.ok;
}
const result = await apiCall<User>("/api/user/1");
if (isApiSuccess(result)) {
// result is narrowed to the success variant
console.log(result.data.email);
} else {
// result is narrowed to the error variant
console.error(result.code);
}Wire this into one of your tests. The narrowing is the payoff — result.data is unreachable on the error branch, result.code is unreachable on the success branch. The compiler proves the test only ever reads fields that actually exist in that case.
Stretch goal 3 — conditional response types
This is the showpiece of the type system, and the optional capstone of the capstone:
type Endpoint = "user" | "product" | "order";
type ResponseData<T extends Endpoint> =
T extends "user" ? User :
T extends "product" ? Product :
T extends "order" ? Order :
never;
async function apiRequestByEndpoint<T extends Endpoint>(endpoint: T): Promise<ResponseData<T>> {
const res = await fetch(`/api/${endpoint}`);
return res.json();
}
const u = await apiRequestByEndpoint("user"); // typed as User
const p = await apiRequestByEndpoint("product"); // typed as Product
const o = await apiRequestByEndpoint("order"); // typed as OrderThe ResponseData<T> conditional type maps endpoint names to their response shapes. The compiler picks the right branch for each call, and the function body returns a value with the right type without you ever having to say so. This is the technique chapter 6's mapped/conditional lesson hinted at — and one you'll see inside @types/cypress and @playwright/test when you go reading library source.
If you wire this into your framework's apiRequest, you've replicated, in miniature, the way Playwright and Cypress type their own helper functions.
Strict mode audit
Even with strict: true, there are extra flags worth turning on:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
}
}noUncheckedIndexedAccess is the most impactful — it forces you to check that array indexes aren't undefined before using the value (array[0] becomes string | undefined). This catches the bug where a fixture array is shorter than the test assumed. Try turning it on and fixing the compile errors; some you'll resolve with explicit length checks, others by switching to .find or .at with type guards.
Where to go next
- – Custom commands deep dive
- – Cypress component testing
- – Network mocking with cy.intercept
- – Advanced fixtures
- – API testing with request
- – Visual regression with screenshots
- – Incremental adoption
- – allowJs and checkJs
- – Type a legacy suite step-by-step
- API testing with Pactum/SuperTest –
- CI/CD for typed test suites –
- Test data management at scale –
If your team uses Cypress, the dedicated Cypress + TypeScript course goes deep on custom commands, component testing, and network mocking. If you've moved to Playwright, the Playwright + TypeScript course covers advanced fixtures, the request API, and visual regression. If you're staring at a legacy JavaScript suite that needs typing, the migration course walks the incremental path — allowJs, checkJs, file-by-file conversion, the order to do it in to avoid breaking everything at once.
The capstone you've just built is the foundation every one of those courses builds on. You're done with the introductory material; everything ahead is depth in a specific direction.
Final thought
Most testers learn TypeScript by accident — a project picks it, the tests start failing in unfamiliar ways, and the language gets absorbed in pieces. You've taken the systematic path instead. Type annotations, inference, interfaces, generics, utility types, type guards, mapped types, framework integration — every tool has a name, a purpose, and a place. The next time the compiler refuses something, you'll recognise which chapter the answer lives in.
Now write tests. The framework is the small, reusable scaffolding; the value comes from the suite of tests you build on top, the bugs they catch, and the speed at which you can change the app underneath without breaking them. Every typed page object, every typed factory, every typed API call is a small refusal to accept the bug class that matching JS code would have allowed.
That's the course. Go ship a typed test suite.