TypeScript Interfaces Practice Programs with Solutions (30 Programs)

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.

Advertisement

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.

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&lt;T&gt; 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&lt;Fixtures&gt;() works the same way: an interface describes the fixtures, and test callbacks get them fully typed.

Keep Practising

Leave a Comment