Practise TypeScript interfaces and types with 30 programs, each with a full solution, the exact output and a short explanation. The programs start with the basics, move to intermediate problems and finish with real-world scenarios from test automation, such as test data, prices, form inputs and test results. They are written for testers learning TypeScript for Playwright.
How to practise: read the task, write your own solution first, then open Show solution to compare. Run any program with npx tsx program.ts (or compile it with npx tsc).
New to this topic? Read the TypeScript Interfaces lesson first.
- Beginner programs (1–10)
- Intermediate programs (11–20)
- Real-world QA scenario programs (21–30)
TypeScript Beginner Programs
1. Declare an interfaceBeginner
Describe a user with an interface and create one.
Show solution
interface User {
id: number;
email: string;
}
const user: User = { id: 1, email: 'qa@test.io' };
console.log(`#${user.id} ${user.email}`);
Output
#1 qa@test.io
Explanation: an interface names an object shape. Any value annotated with it must have those properties with those types.
2. Interface catches wrong typesBeginner
Show that a string id is rejected by a numeric interface field.
Show solution
interface TestCase {
id: number;
title: string;
}
const good: TestCase = { id: 7, title: 'login' };
// @ts-expect-error: Type 'string' is not assignable to type 'number'
const bad: TestCase = { id: '7', title: 'login' };
console.log(good.id, bad.title);
Output
7 login
Explanation: each field is checked against the interface, so a mismatched type fails to compile.
3. Optional membersBeginner
Add an optional tags field and print it only when present.
Show solution
interface Spec {
title: string;
tags?: string[];
}
const specs: Spec[] = [{ title: 'login', tags: ['smoke'] }, { title: 'search' }];
for (const s of specs) console.log(`${s.title}${s.tags ? ' [' + s.tags.join(',') + ']' : ''}`);
Output
login [smoke]
search
Explanation: tags? may be missing, so its type is string[] | undefined and you check it before use.
4. Readonly membersBeginner
Make an interface field readonly.
Show solution
interface Build {
readonly number: number;
status: string;
}
const build: Build = { number: 512, status: 'running' };
build.status = 'passed';
// @ts-expect-error: Cannot assign to 'number' because it is a read-only property
const bump = () => (build.number = 513);
console.log(build.number, build.status, typeof bump);
Output
512 passed function
Explanation: readonly lets you set the value once when the object is created and blocks later changes.
5. Interface as a function parameterBeginner
Write a function that accepts any object matching an interface.
Show solution
interface Credentials {
username: string;
password: string;
}
function maskedLogin(c: Credentials): string {
return `${c.username} / ${'*'.repeat(c.password.length)}`;
}
console.log(maskedLogin({ username: 'admin', password: 'secret' }));
Output
admin / ******
Explanation: the parameter type documents what the function needs, and every call is checked.
6. Methods in an interfaceBeginner
Describe a logger with methods and implement it as an object.
Show solution
interface Logger {
info(msg: string): void;
error(msg: string): void;
}
const logger: Logger = {
info: msg => console.log(`INFO ${msg}`),
error: msg => console.log(`ERROR ${msg}`),
};
logger.info('suite started');
logger.error('login failed');
Output
INFO suite started
ERROR login failed
Explanation: method signatures type the parameters, so msg is a string in the implementation without repeating the annotation.
7. Function type in an interfaceBeginner
Describe a validator as a callable interface.
Show solution
interface Validator {
(value: string): boolean;
}
const isEmail: Validator = v => /^[^@\s]+@[^@\s]+\.[a-z]+$/i.test(v);
console.log(isEmail('qa@test.io'), isEmail('qa-at-test'));
Output
true false
Explanation: a call signature in an interface describes a function. A type Validator = (value: string) => boolean alias would do the same.
8. Array of interface objectsBeginner
Total the price of items in a cart.
Show solution
interface CartItem {
name: string;
price: number;
qty: number;
}
const cart: CartItem[] = [
{ name: 'pen', price: 1.5, qty: 4 },
{ name: 'book', price: 12, qty: 1 },
];
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
console.log(`total $${total.toFixed(2)}`);
Output
total $18.00
Explanation: the element type gives the reduce callback typed access to price and qty.
9. Nested interfacesBeginner
Use one interface inside another.
Show solution
interface Address {
city: string;
zip: string;
}
interface Customer {
name: string;
address: Address;
}
const c: Customer = { name: 'Ana', address: { city: 'Pune', zip: '411001' } };
console.log(`${c.name}, ${c.address.city} ${c.address.zip}`);
Output
Ana, Pune 411001
Explanation: small interfaces can be combined, so the same Address can be reused in orders, invoices and profiles.
10. Index signature in an interfaceBeginner
Describe a map of environment URLs.
Show solution
interface EnvUrls {
[env: string]: string;
}
const urls: EnvUrls = { dev: 'http://localhost:3000', staging: 'https://staging.example.com' };
urls.prod = 'https://example.com';
console.log(Object.keys(urls).join(', '));
Output
dev, staging, prod
Explanation: the index signature allows any string key, as long as every value is a string.
TypeScript Intermediate Programs
11. Extend an interfaceIntermediate
Build Admin on top of User with extends.
Show solution
interface User {
id: number;
email: string;
}
interface Admin extends User {
permissions: string[];
}
const admin: Admin = { id: 1, email: 'root@test.io', permissions: ['read', 'write', 'delete'] };
console.log(`${admin.email} can ${admin.permissions.join('/')}`);
Output
root@test.io can read/write/delete
Explanation: extends copies the base members, so Admin needs id, email and permissions.
12. Extend multiple interfacesIntermediate
Combine Timestamps and Identifiable into one interface.
Show solution
interface Identifiable {
id: string;
}
interface Timestamps {
createdAt: Date;
}
interface Ticket extends Identifiable, Timestamps {
title: string;
}
const t: Ticket = { id: 'BUG-12', createdAt: new Date('2026-01-15T00:00:00Z'), title: 'Login button misaligned' };
console.log(`${t.id} (${t.createdAt.toISOString().slice(0, 10)}): ${t.title}`);
Output
BUG-12 (2026-01-15): Login button misaligned
Explanation: an interface can extend several others at once, a clean way to share common fields.
13. Class implements an interfaceIntermediate
Make a class promise to follow an interface.
Show solution
interface Shape {
area(): number;
}
class Rect implements Shape {
constructor(private w: number, private h: number) {}
area(): number {
return this.w * this.h;
}
}
const shapes: Shape[] = [new Rect(2, 3), new Rect(4, 5)];
console.log(shapes.map(s => s.area()).join(', '));
Output
6, 20
Explanation: implements makes the compiler check that the class has every member of the interface.
14. Missing member in implementsIntermediate
Show the error when a class forgets an interface method.
Show solution
interface Page {
open(): string;
title(): string;
}
// @ts-expect-error: Class 'HomePage' incorrectly implements interface 'Page'
class HomePage implements Page {
open(): string {
return 'opened /';
}
}
console.log(new HomePage().open());
Output
opened /
Explanation: the class compiles only when it has both open and title. This keeps every page object consistent.
15. Interface vs type aliasIntermediate
Describe the same shape with an interface and a type alias and use both.
Show solution
interface PointI {
x: number;
y: number;
}
type PointT = { x: number; y: number };
const a: PointI = { x: 1, y: 2 };
const b: PointT = a;
console.log(b.x + b.y);
Output
3
Explanation: for object shapes the two are interchangeable. Interfaces can be extended and merged; type aliases can also name unions and tuples.
16. Union types need a type aliasIntermediate
Model a status that is one of three strings and use it inside an interface.
Show solution
type Status = 'passed' | 'failed' | 'skipped';
interface Result {
name: string;
status: Status;
}
const results: Result[] = [
{ name: 'login', status: 'passed' },
{ name: 'cart', status: 'failed' },
];
console.log(results.filter(r => r.status === 'failed').map(r => r.name).join(', '));
Output
cart
Explanation: an interface cannot describe a union of literals, so a type alias holds the union and the interface uses it.
17. Declaration mergingIntermediate
Declare the same interface twice and see the members combine.
Show solution
interface Settings {
theme: string;
}
interface Settings {
fontSize: number;
}
const s: Settings = { theme: 'dark', fontSize: 14 };
console.log(`${s.theme} ${s.fontSize}px`);
Output
dark 14px
Explanation: interfaces with the same name merge. This is how libraries let you add fields to their types, for example Playwright fixtures.
18. Generic interfaceIntermediate
Write an ApiResponse<T> interface and use it with two data types.
Show solution
interface ApiResponse<T> {
status: number;
data: T;
}
const user: ApiResponse<{ name: string }> = { status: 200, data: { name: 'Ana' } };
const ids: ApiResponse<number[]> = { status: 200, data: [1, 2, 3] };
console.log(user.data.name, ids.data.length);
Output
Ana 3
Explanation: the type parameter lets one interface describe responses with any payload while still typing data.
19. Interface for constructor optionsIntermediate
Use an interface with optional fields as constructor options.
Show solution
interface BrowserOptions {
headless?: boolean;
slowMo?: number;
}
class FakeBrowser {
readonly headless: boolean;
readonly slowMo: number;
constructor(opts: BrowserOptions = {}) {
this.headless = opts.headless ?? true;
this.slowMo = opts.slowMo ?? 0;
}
}
const b1 = new FakeBrowser();
const b2 = new FakeBrowser({ headless: false, slowMo: 50 });
console.log(b1.headless, b1.slowMo, b2.headless, b2.slowMo);
Output
true 0 false 50
Explanation: an options interface keeps the constructor flexible, and defaults fill in what callers leave out.
20. Readonly arrays in an interfaceIntermediate
Protect a list inside an interface from being changed.
Show solution
interface Suite {
name: string;
readonly tests: readonly string[];
}
const suite: Suite = { name: 'auth', tests: ['login', 'logout'] };
// @ts-expect-error: Property 'push' does not exist on type 'readonly string[]'
const add = () => suite.tests.push('reset');
console.log(suite.tests.join(', '), typeof add);
Output
login, logout function
Explanation: readonly on the property stops reassignment, and readonly string[] stops changes to the array itself.
TypeScript Real-World QA Scenario Programs
21. Interface for a page objectReal-world
Describe a login page with an interface and give it a fake implementation.
Show solution
interface LoginPage {
goto(): Promise<void>;
login(user: string, pass: string): Promise<string>;
}
const fakeLogin: LoginPage = {
async goto() {
console.log('navigate /login');
},
async login(user, pass) {
return pass === 'secret' ? `welcome ${user}` : 'invalid credentials';
},
};
await fakeLogin.goto();
console.log(await fakeLogin.login('qa', 'secret'));
console.log(await fakeLogin.login('qa', 'wrong'));
Output
navigate /login
welcome qa
invalid credentials
Explanation: coding against an interface lets you swap a real Playwright page object for a fake in unit tests.
22. Discriminated union of interfacesReal-world
Handle three kinds of test events with a kind field.
Show solution
interface StartEvent { kind: 'start'; test: string }
interface PassEvent { kind: 'pass'; test: string; ms: number }
interface FailEvent { kind: 'fail'; test: string; error: string }
type TestEvent = StartEvent | PassEvent | FailEvent;
function format(e: TestEvent): string {
switch (e.kind) {
case 'start': return `> ${e.test}`;
case 'pass': return `ok ${e.test} (${e.ms}ms)`;
case 'fail': return `x ${e.test}: ${e.error}`;
}
}
const events: TestEvent[] = [
{ kind: 'start', test: 'login' },
{ kind: 'pass', test: 'login', ms: 840 },
{ kind: 'fail', test: 'cart', error: 'timeout' },
];
events.forEach(e => console.log(format(e)));
Output
> login
ok login (840ms)
x cart: timeout
Explanation: the kind literal narrows the union in each case, so ms and error are only used where they exist.
23. Type guard for an interfaceReal-world
Check unknown JSON at runtime before treating it as a User.
Show solution
interface User {
id: number;
email: string;
}
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null
&& typeof (x as User).id === 'number' && typeof (x as User).email === 'string';
}
const inputs: unknown[] = [JSON.parse('{"id":1,"email":"a@test.io"}'), JSON.parse('{"id":"2"}')];
for (const i of inputs) console.log(isUser(i) ? `valid: ${i.email}` : 'invalid payload');
Output
valid: a@test.io
invalid payload
Explanation: interfaces disappear at runtime, so data from an API must be checked. The x is User guard narrows the type after the check.
24. Interface with a generic constraintReal-world
Write a function that sorts anything with a priority field.
Show solution
interface HasPriority {
priority: number;
}
function byPriority<T extends HasPriority>(items: T[]): T[] {
return [...items].sort((a, b) => a.priority - b.priority);
}
const bugs = [
{ id: 'B-3', priority: 2 },
{ id: 'B-1', priority: 1 },
{ id: 'B-9', priority: 3 },
];
console.log(byPriority(bugs).map(b => b.id).join(' > '));
Output
B-1 > B-3 > B-9
Explanation: T extends HasPriority requires the field but keeps the full item type, so b.id is still available in the result.
25. Callable and property members togetherReal-world
Describe a counter function that also exposes a count property.
Show solution
interface Counter {
(): number;
count: number;
reset(): void;
}
function makeCounter(): Counter {
const c = (() => ++c.count) as Counter;
c.count = 0;
c.reset = () => { c.count = 0; };
return c;
}
const next = makeCounter();
next(); next();
console.log(next(), next.count);
next.reset();
console.log(next.count);
Output
3 3
0
Explanation: an interface can mix a call signature with properties, which describes functions that carry state.
26. Extending a generic interfaceReal-world
Build a paginated response on top of a generic base response.
Show solution
interface ApiResponse<T> {
status: number;
data: T;
}
interface Paged<T> extends ApiResponse<T[]> {
page: number;
totalPages: number;
}
const page2: Paged<string> = { status: 200, data: ['c', 'd'], page: 2, totalPages: 3 };
const hasMore = page2.page < page2.totalPages;
console.log(`${page2.data.join(',')} | page ${page2.page}/${page2.totalPages} | more: ${hasMore}`);
Output
c,d | page 2/3 | more: true
Explanation: a generic interface can extend another generic one and fix part of its type, here data becomes T[].
27. Repository interface with a fakeReal-world
Define a data repository interface and an in-memory implementation.
Show solution
interface Repo<T extends { id: number }> {
add(item: T): void;
get(id: number): T | undefined;
all(): T[];
}
class MemoryRepo<T extends { id: number }> implements Repo<T> {
private items = new Map<number, T>();
add(item: T): void { this.items.set(item.id, item); }
get(id: number): T | undefined { return this.items.get(id); }
all(): T[] { return [...this.items.values()]; }
}
const users = new MemoryRepo<{ id: number; name: string }>();
users.add({ id: 1, name: 'Ana' });
users.add({ id: 2, name: 'Ben' });
console.log(users.get(2)?.name, users.get(3)?.name ?? 'missing', users.all().length);
Output
Ben missing 2
Explanation: tests can depend on Repo<T> and use the memory version, while production code uses a database version with the same interface.
28. Optional methodsReal-world
Call a hook only if the plugin defines it.
Show solution
interface Plugin {
name: string;
beforeAll?(): string;
afterAll?(): string;
}
const plugins: Plugin[] = [
{ name: 'screenshots', afterAll: () => 'saved 3 screenshots' },
{ name: 'auth', beforeAll: () => 'logged in' },
];
for (const p of plugins) console.log(`${p.name}: ${p.beforeAll?.() ?? '-'} | ${p.afterAll?.() ?? '-'}`);
Output
screenshots: - | saved 3 screenshots
auth: logged in | -
Explanation: optional methods may be missing, so ?.() calls them only when they exist.
29. Mapped type over an interfaceReal-world
Turn every field of an interface into a validation message slot.
Show solution
interface SignupForm {
email: string;
password: string;
age: number;
}
type FormErrors = { [K in keyof SignupForm]?: string };
function validate(f: SignupForm): FormErrors {
const errors: FormErrors = {};
if (!f.email.includes('@')) errors.email = 'invalid email';
if (f.password.length < 8) errors.password = 'too short';
if (f.age < 18) errors.age = 'must be 18+';
return errors;
}
console.log(JSON.stringify(validate({ email: 'qa', password: 'secret', age: 30 })));
Output
{"email":"invalid email","password":"too short"}
Explanation: the mapped type reuses the interface keys, so adding a form field automatically allows an error for it.
30. Fixture types like PlaywrightReal-world
Model test fixtures with an interface and use them in a fake test runner.
Show solution
interface Fixtures {
baseURL: string;
user: { email: string };
}
type TestFn = (f: Fixtures) => string;
function runTest(name: string, fn: TestFn): void {
const fixtures: Fixtures = { baseURL: 'https://example.com', user: { email: 'qa@test.io' } };
console.log(`${name}: ${fn(fixtures)}`);
}
runTest('profile', ({ baseURL, user }) => `${baseURL}/profile as ${user.email}`);
Output
profile: https://example.com/profile as qa@test.io
Explanation: Playwright’s test.extend<Fixtures>() works the same way: an interface describes the fixtures, and test callbacks get them fully typed.