Practise TypeScript conditions and narrowing 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 Conditions lesson first.
- Beginner programs (1–10)
- Intermediate programs (11–20)
- Real-world QA scenario programs (21–30)
TypeScript Beginner Programs
1. Positive, negative or zeroBeginner
Write sign(n: number) that returns 'positive', 'negative' or 'zero'.
Show solution
type Sign = 'positive' | 'negative' | 'zero';
function sign(n: number): Sign {
if (n > 0) return 'positive';
if (n < 0) return 'negative';
return 'zero';
}
console.log(sign(7), sign(-2), sign(0));
Output
positive negative zero
Explanation: the return type is a literal union, so every branch must return one of the allowed strings.
2. Pass or failBeginner
Return 'PASS' when a score is at least 60, otherwise 'FAIL'.
Show solution
function result(score: number): 'PASS' | 'FAIL' {
if (score >= 60) {
return 'PASS';
} else {
return 'FAIL';
}
}
console.log(result(72), result(45));
Output
PASS FAIL
Explanation: if/else picks one of two outcomes. The return type documents both possibilities.
3. Grade bandsBeginner
Convert marks into grades A, B, C or F with an else if chain.
Show solution
type Grade = 'A' | 'B' | 'C' | 'F';
function grade(marks: number): Grade {
if (marks >= 90) return 'A';
else if (marks >= 75) return 'B';
else if (marks >= 60) return 'C';
else return 'F';
}
console.log([95, 80, 61, 30].map(grade).join(' '));
Output
A B C F
Explanation: checking the highest threshold first ensures each value lands in the right band.
4. Leap yearBeginner
Write isLeapYear(year: number): boolean and test three years.
Show solution
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
for (const y of [2024, 1900, 2000]) console.log(y, isLeapYear(y));
Output
2024 true
1900 false
2000 true
Explanation: the whole condition is a boolean expression, so it can be returned directly.
5. switch on a string unionBeginner
Map a day name to a working-day flag with switch.
Show solution
type Day = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
function isWorkingDay(day: Day): boolean {
switch (day) {
case 'Sat':
case 'Sun':
return false;
default:
return true;
}
}
console.log(isWorkingDay('Mon'), isWorkingDay('Sun'));
Output
true false
Explanation: TypeScript checks that each case is a member of the Day union, so typos are caught.
6. Ternary with typesBeginner
Choose a label with a ternary and store it in a typed variable.
Show solution
const itemCount: number = 1;
const label: string = itemCount === 1 ? 'item' : 'items';
console.log(`${itemCount} ${label}`);
Output
1 item
Explanation: both branches return strings, so the result type is string.
7. Handle a nullable valueBeginner
Print a greeting when a name is present, otherwise a default message.
Show solution
function greet(name: string | null): string {
if (name === null) return 'Hello, guest';
return `Hello, ${name.toUpperCase()}`;
}
console.log(greet('asha'));
console.log(greet(null));
Output
Hello, ASHA
Hello, guest
Explanation: after the null check, TypeScript narrows name to string, so toUpperCase is allowed.
8. Optional parameter checkBeginner
Use a default when an optional parameter is missing.
Show solution
function openPage(path: string, timeout?: number): string {
if (timeout === undefined) return `${path} (default timeout)`;
return `${path} (${timeout} ms)`;
}
console.log(openPage('/login'));
console.log(openPage('/reports', 60000));
Output
/login (default timeout)
/reports (60000 ms)
Explanation: an optional parameter has type number | undefined, so you check for undefined before using it.
9. Truthiness narrowingBeginner
Print the length of a string only when it is non-empty.
Show solution
function describe(text?: string): string {
if (text) return `${text.length} characters`;
return 'empty or missing';
}
console.log(describe('Playwright'), '|', describe(''), '|', describe());
Output
10 characters | empty or missing | empty or missing
Explanation: if (text) removes undefined and also skips the empty string, which is falsy.
10. Largest of threeBeginner
Return the largest of three numbers with if conditions.
Show solution
function largest(a: number, b: number, c: number): number {
if (a >= b && a >= c) return a;
if (b >= c) return b;
return c;
}
console.log(largest(12, 45, 30), largest(9, 3, 9));
Output
45 9
Explanation: once a is ruled out, only b and c remain to be compared.
TypeScript Intermediate Programs
11. typeof narrowingIntermediate
Format an input that may be a number, string or boolean.
Show solution
function format(value: number | string | boolean): string {
if (typeof value === 'number') return value.toFixed(1);
if (typeof value === 'boolean') return value ? 'yes' : 'no';
return value.trim();
}
console.log(format(3.14159), format(true), format(' ok '));
Output
3.1 yes ok
Explanation: each typeof check narrows the union, and the final return knows only string is left.
12. Discriminated union with ifIntermediate
Handle API responses that are either success or error.
Show solution
type Res = { status: 'ok'; data: string[] } | { status: 'error'; message: string };
function handle(res: Res): string {
if (res.status === 'ok') return `Loaded ${res.data.length} rows`;
return `Error: ${res.message}`;
}
console.log(handle({ status: 'ok', data: ['a', 'b'] }));
console.log(handle({ status: 'error', message: 'Unauthorized' }));
Output
Loaded 2 rows
Error: Unauthorized
Explanation: checking the shared status field narrows to the matching member of the union.
13. Exhaustive switch with neverIntermediate
Map test outcomes to symbols and make the compiler check every case.
Show solution
type Outcome = 'passed' | 'failed' | 'skipped' | 'flaky';
function symbol(o: Outcome): string {
switch (o) {
case 'passed': return '✔';
case 'failed': return '✘';
case 'skipped': return '○';
case 'flaky': return '≈';
default: {
const unhandled: never = o;
throw new Error(`Unhandled outcome: ${unhandled}`);
}
}
}
console.log((['passed', 'flaky', 'failed'] as Outcome[]).map(symbol).join(' '));
Output
✔ ≈ ✘
Explanation: adding a new outcome without a case makes the never assignment fail to compile.
14. in operator narrowingIntermediate
Tell a Card payment from a Upi payment.
Show solution
type Card = { cardNumber: string; expiry: string };
type Upi = { upiId: string };
function paymentLabel(p: Card | Upi): string {
if ('cardNumber' in p) return `Card ending ${p.cardNumber.slice(-4)}`;
return `UPI ${p.upiId}`;
}
console.log(paymentLabel({ cardNumber: '4111111111111234', expiry: '12/28' }));
console.log(paymentLabel({ upiId: 'qa@bank' }));
Output
Card ending 1234
UPI qa@bank
Explanation: checking for a property with in narrows a union of object types.
15. instanceof narrowingIntermediate
Handle a caught error safely, whatever was thrown.
Show solution
function message(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return 'Unknown error';
}
console.log(message(new Error('Timeout')), '|', message('Network down'), '|', message(42));
Output
Timeout | Network down | Unknown error
Explanation: in strict mode a caught error is unknown, so narrow it before reading message.
16. Custom type guardIntermediate
Write isNumberArray and use it in a condition.
Show solution
function isNumberArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every(v => typeof v === 'number');
}
for (const input of [[1, 2, 3], ['1', 2], 'x'] as unknown[]) {
if (isNumberArray(input)) console.log('sum', input.reduce((a, b) => a + b, 0));
else console.log('not a number array');
}
Output
sum 6
not a number array
not a number array
Explanation: the value is number[] return type narrows the variable in the if branch.
17. Equality narrowingIntermediate
Narrow two union-typed values by comparing them.
Show solution
function compare(a: string | number, b: string | boolean): string {
if (a === b) return `both are the string "${a.toUpperCase()}"`;
return 'different';
}
console.log(compare('qa', 'qa'));
console.log(compare(1, true));
Output
both are the string "QA"
different
Explanation: if a === b, both must be strings (the only shared type), so toUpperCase is allowed.
18. Lookup object instead of switchIntermediate
Map HTTP status codes to messages with a typed record and a default.
Show solution
const STATUS_TEXT: Record<number, string> = { 200: 'OK', 201: 'Created', 404: 'Not Found', 500: 'Server Error' };
function statusText(code: number): string {
return STATUS_TEXT[code] ?? 'Unknown';
}
console.log([200, 404, 418].map(statusText).join(', '));
Output
OK, Not Found, Unknown
Explanation: a lookup table replaces long switch statements. ?? supplies the default.
19. Guard clauses with typesIntermediate
Validate an age value with early returns.
Show solution
function validateAge(age: unknown): string {
if (typeof age !== 'number') return 'Age must be a number';
if (!Number.isInteger(age)) return 'Age must be a whole number';
if (age < 18) return 'Must be 18 or older';
return 'Valid';
}
console.log([25, '25', 17.5, 16].map(validateAge).join(' | '));
Output
Valid | Age must be a number | Age must be a whole number | Must be 18 or older
Explanation: after the first guard, age is a number for all the following checks.
20. Assertion functionIntermediate
Write an assertDefined function that narrows a value after it runs.
Show solution
function assertDefined<T>(value: T | undefined, name: string): asserts value is T {
if (value === undefined) throw new Error(`${name} is required`);
}
const env: Record<string, string | undefined> = { BASE_URL: 'https://qa.app.test' };
const baseUrl = env.BASE_URL;
assertDefined(baseUrl, 'BASE_URL');
console.log(baseUrl.toUpperCase());
try {
assertDefined(env.TOKEN, 'TOKEN');
} catch (e) {
console.log(e instanceof Error ? e.message : e);
}
Output
HTTPS://QA.APP.TEST
TOKEN is required
Explanation: asserts value is T tells TypeScript the value is defined after the call, like expect(...).toBeDefined() combined with type narrowing.
TypeScript Real-World QA Scenario Programs
21. Shipping cost rulesReal-world
Calculate shipping with typed thresholds, then test the boundary values.
Show solution
const FREE_ABOVE = 999, MID_FROM = 500;
function shipping(total: number): number {
if (total > FREE_ABOVE) return 0;
if (total >= MID_FROM) return 49;
return 99;
}
for (const t of [499, 500, 999, 1000]) console.log(t, shipping(t));
Output
499 99
500 49
999 49
1000 0
Explanation: boundary values (499, 500, 999, 1000) are where off-by-one bugs appear, so test them explicitly.
22. Environment selectionReal-world
Choose a base URL from an environment name with an exhaustive switch.
Show solution
type Env = 'dev' | 'qa' | 'prod';
function baseUrl(env: Env): string {
switch (env) {
case 'dev': return 'http://localhost:3000';
case 'qa': return 'https://qa.shop.test';
case 'prod': return 'https://shop.test';
}
}
console.log(baseUrl('qa'));
Output
https://qa.shop.test
Explanation: because every Env member returns, TypeScript knows the function never falls through without a value.
23. Password strength rulesReal-world
Return the first failing password rule, or 'Strong'.
Show solution
function passwordIssue(pw: string): string {
if (pw.length < 8) return 'Too short';
if (!/[A-Z]/.test(pw)) return 'Needs an uppercase letter';
if (!/\d/.test(pw)) return 'Needs a digit';
if (!/[^A-Za-z0-9]/.test(pw)) return 'Needs a symbol';
return 'Strong';
}
for (const pw of ['abc', 'abcdefgh', 'Abcdefgh', 'Abcdefg1', 'Abcdef1!']) console.log(pw, '->', passwordIssue(pw));
Output
abc -> Too short
abcdefgh -> Needs an uppercase letter
Abcdefgh -> Needs a digit
Abcdefg1 -> Needs a symbol
Abcdef1! -> Strong
Explanation: ordered guard clauses mirror the order in which a form shows validation messages.
24. Classify an API responseReal-world
Classify typed responses into success, client error or server error.
Show solution
type ApiResponse = { status: number; body?: { items: string[] } };
function classify(r: ApiResponse): string {
if (r.status >= 500) return 'server error';
if (r.status >= 400) return 'client error';
if (!r.body || r.body.items.length === 0) return 'success (empty)';
const n = r.body.items.length;
return `success (${n} item${n === 1 ? '' : 's'})`;
}
const responses: ApiResponse[] = [{ status: 200, body: { items: ['a'] } }, { status: 200 }, { status: 404 }, { status: 503 }];
for (const r of responses) console.log(r.status, classify(r));
Output
200 success (1 item)
200 success (empty)
404 client error
503 server error
Explanation: checking error statuses first and body before items keeps every access type-safe.
25. Feature access by planReal-world
Decide whether a plan can use a feature.
Show solution
type Plan = 'free' | 'pro' | 'enterprise';
type Feature = 'reports' | 'sso' | 'export';
function canUse(plan: Plan, feature: Feature): boolean {
if (feature === 'sso') return plan === 'enterprise';
if (feature === 'reports') return plan !== 'free';
return true;
}
for (const plan of ['free', 'pro', 'enterprise'] as Plan[]) {
console.log(plan, canUse(plan, 'reports'), canUse(plan, 'sso'), canUse(plan, 'export'));
}
Output
free false false true
pro true false true
enterprise true true true
Explanation: literal unions for both inputs make invalid plan or feature names a compile error.
26. Test priority triageReal-world
Assign P1, P2 or P3 to typed test cases.
Show solution
type TestCase = { id: string; area: string; blocker: boolean; regression: boolean };
type Priority = 'P1' | 'P2' | 'P3';
function priority(t: TestCase): Priority {
if (t.area === 'checkout' || t.blocker) return 'P1';
if (t.regression) return 'P2';
return 'P3';
}
const tests: TestCase[] = [
{ id: 'TC1', area: 'checkout', blocker: false, regression: true },
{ id: 'TC2', area: 'search', blocker: false, regression: true },
{ id: 'TC3', area: 'help', blocker: false, regression: false }
];
console.log(tests.map(t => `${t.id}:${priority(t)}`).join(' '));
Output
TC1:P1 TC2:P2 TC3:P3
Explanation: the rule reads like the written triage policy, and the types document the inputs.
27. Flaky test classificationReal-world
Classify a test from its pass count over a number of runs.
Show solution
type Stability = 'stable' | 'flaky' | 'broken';
function stability(passes: number, runs: number): Stability {
if (passes === runs) return 'stable';
if (passes === 0) return 'broken';
return 'flaky';
}
for (const [p, r] of [[10, 10], [7, 10], [0, 10]] as [number, number][]) console.log(`${p}/${r}`, stability(p, r));
Output
10/10 stable
7/10 flaky
0/10 broken
Explanation: the typed tuple array makes it clear each entry is a pair of numbers.
28. Retry decisionReal-world
Retry only on server errors or 429, while attempts remain.
Show solution
type Attempt = { status: number; attempt: number };
const MAX_ATTEMPTS = 3;
function shouldRetry({ status, attempt }: Attempt): boolean {
if (attempt >= MAX_ATTEMPTS) return false;
return status >= 500 || status === 429;
}
const cases: Attempt[] = [{ status: 503, attempt: 1 }, { status: 404, attempt: 1 }, { status: 429, attempt: 3 }];
for (const c of cases) console.log(c.status, c.attempt, shouldRetry(c));
Output
503 1 true
404 1 false
429 3 false
Explanation: a guard clause handles the attempt limit first, so the status rule stays simple.
29. Weekend scheduling checkReal-world
Skip weekend dates when scheduling a nightly run, using UTC days.
Show solution
function shouldRun(isoDate: string): boolean {
const day = new Date(`${isoDate}T00:00:00Z`).getUTCDay();
return day !== 0 && day !== 6;
}
for (const d of ['2026-11-06', '2026-11-07', '2026-11-09']) console.log(d, shouldRun(d) ? 'run' : 'skip');
Output
2026-11-06 run
2026-11-07 skip
2026-11-09 run
Explanation: getUTCDay() returns 0 for Sunday and 6 for Saturday, independent of the machine’s time zone.
30. Locator choice by available attributesReal-world
Pick the best locator strategy from what an element provides.
Show solution
type ElementInfo = { role?: string; name?: string; testId?: string; css: string };
function bestLocator(el: ElementInfo): string {
if (el.role && el.name) return `getByRole('${el.role}', { name: '${el.name}' })`;
if (el.testId) return `getByTestId('${el.testId}')`;
return `locator('${el.css}')`;
}
console.log(bestLocator({ role: 'button', name: 'Pay', css: '.btn' }));
console.log(bestLocator({ testId: 'cart-total', css: '#total' }));
console.log(bestLocator({ css: 'div > span:nth-child(2)' }));
Output
getByRole('button', { name: 'Pay' })
getByTestId('cart-total')
locator('div > span:nth-child(2)')
Explanation: conditions encode Playwright’s locator priority: role and name first, then test ID, then CSS.