QA Interview Hub · Knowledge base · Quizzes
TypeScript
JS traps: numbers and money, strings, dates, objects and arrays
Floating-point numbers and money
number in JS is a 64-bit floating-point number (IEEE 754). Decimal fractions like 0.1 have no exact binary representation.
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
1.005 * 100; // 100.49999999999999
(1.005).toFixed(2); // '1.00', not '1.01'
toFixed rounds the binary value, not the decimal one you see in the code, so the result is sometimes unexpected. Besides, toFixed returns a string.
How to work with money correctly:
- Store amounts in minor units (cents) as integers:
10050 instead of 100.50. Integers up to Number.MAX_SAFE_INTEGER (2^53 - 1) are represented exactly. - Know the number of minor units for the currency: most have 2, but JPY has 0, and some currencies have 3 (for example KWD).
- For fractional calculations (percentages, rates, fees) use a decimal library (
decimal.js, big.js) with an explicit rounding mode. Banks often use banker's rounding (half-even), and the test must know exactly which rule is in the specification. - For very large integers there is
BigInt, but it doesn't mix with number in arithmetic.
const toMinor = (amount: string): number => Math.round(Number(amount) * 100);
// better through a library:
// new Decimal('100.505').toDecimalPlaces(2, Decimal.ROUND_HALF_EVEN)
In tests: I compare amounts in minor units with toBe, not fractional ones with toBe. If I need to compare fractional values, I use toBeCloseTo with a precision. For display I use Intl.NumberFormat, not manual concatenation.
In an interview: 0.1 + 0.2 in JS doesn't equal 0.3 because of the binary representation, and toFixed also rounds inaccurately. So I compare money in minor units as integers, and compute fees and rates with a decimal library with an explicit rounding rule from the specification.
Converting strings to numbers
There are several ways to turn a string into a number, and they behave differently:
Number('42'); // 42
Number(''); // 0 - a trap!
Number(' '); // 0
Number('12px'); // NaN
Number('1e3'); // 1000
Number(null); // 0
Number(undefined); // NaN
parseInt('12px', 10); // 12 - takes the prefix
parseInt('1e3', 10); // 1
parseFloat('1,5'); // 1 - a comma is not a separator
parseFloat('1 000.50');// 1
Key traps:
Number('') returns 0. An empty amount field in the UI will turn into zero, and a test may "successfully" check a zero amount.parseInt and parseFloat silently discard everything after the first invalid character. The amount '1 250,50 UAH' from the UI becomes 1.- I always call
parseInt with radix 10. ['1', '2', '3'].map(parseInt) gives [1, NaN, NaN], because map passes the index as the second argument (the radix).NaN !== NaN. The check is Number.isNaN(x), not x === NaN.
For amounts from the UI I write an explicit parser for the specific format: I remove spaces (including the non-breaking space that Intl.NumberFormat uses as a thousands separator), the currency, replace the comma with a dot, check with a regex and only then convert. Even better is to compare with the expected string formatted by the same Intl.NumberFormat with the right locale.
function parseUiAmount(text: string): number {
const clean = text.replace(/[\s ]/g, '').replace('UAH', '').replace(',', '.');
if (!/^-?\d+(\.\d{1,2})?$/.test(clean)) throw new Error(`Bad amount: ${text}`);
return Math.round(Number(clean) * 100); // minor units
}
My principle for amounts from the UI: the parser must fail on an unexpected format, not return part of a number.
In an interview: Number('') gives zero, and parseInt silently truncates a string at the first invalid character, so for amounts from the UI I write a strict parser that fails on an unexpected format. I check NaN with Number.isNaN, and call parseInt only with radix 10.
Comparison: == and ===, ||, ?? and ?.
== performs type coercion before comparing, === doesn't. The coercion rules are unintuitive:
'' == 0; // true
'0' == false; // true
null == undefined; // true
null == 0; // false
NaN == NaN; // false
[1] == 1; // true
I always use ===. The only common exception is x == null, which checks both null and undefined, but even there I prefer explicitness. The ESLint rule eqeqeq enforces this. TS partly catches meaningless comparisons of different types, but not all.
The || operator returns the right operand if the left is falsy. Falsy values: false, 0, -0, 0n, '', null, undefined, NaN. So || for default values breaks valid zeros and empty strings:
const fee = response.fee || 500; // fee = 0 becomes 500 - a bug
const fee2 = response.fee ?? 500; // 0 stays 0
const timeout = Number(env.TIMEOUT) || 30_000; // here || deliberately catches NaN too
?? (nullish coalescing) substitutes a value only for null and undefined. For money, limits and counters this is a critical difference: a zero fee is a valid business case.
?. (optional chaining) stops evaluation and returns undefined if the left part is null or undefined: res.data?.items?.[0]?.amount. A trap in tests: excessive ?. masks a problem. If items must always be there, then expect(body.data?.items?.length).toBeFalsy() passes both for an empty array and for a missing field. So I first check the structure explicitly, and then the value.
In an interview: I always use ===, because == does a non-obvious type coercion. For default values I take ??, not ||, because || would replace a valid zero, such as a zero fee, with the default.
Strings, dates, mutations and sorting
Strings. The operators < and > compare strings by UTF-16 codes: 'B' < 'a' is true, '10' < '9' is true, and Ukrainian letters like і, ї, є don't stand in their places of the alphabet. To check sorting in the UI I use a.localeCompare(b, 'uk') or Intl.Collator. For natural sorting of numbers in strings there is the option { numeric: true }.
Sorting. Array.prototype.sort() without a comparator sorts as strings and mutates the array in place:
[10, 1, 2].sort(); // [1, 10, 2]
[10, 1, 2].sort((a, b) => a - b); // [1, 2, 10]
const sorted = amounts.toSorted((a, b) => a - b); // ES2023, no mutation
Dates. Months in Date are numbered from zero: new Date(2026, 0, 15) is 15 January. A date-only string new Date('2026-01-15') is parsed as UTC, and a date with a time and no zone new Date('2026-01-15T10:00') as local time. getDate() returns the local day, and a test that works in Kyiv fails on CI in UTC near midnight. Overflow: 31 January plus setMonth(1) gives March. My rules: exchange dates in ISO 8601 with Z or an offset, fix TZ on CI and in playwright.config.ts through timezoneId, take a library (date-fns, Luxon) for calendar logic, and control time in tests through page.clock.
Mutations and copies. Objects and arrays are passed by reference. Spread { ...obj } and [...arr] make a shallow copy: nested objects are shared.
const base = { amount: 100, meta: { tags: ['a'] } };
const copy = { ...base };
copy.meta.tags.push('b'); // base changed too
const deep = structuredClone(base); // a deep copy, Node 17+
structuredClone doesn't copy functions (it throws) and loses a class's prototype. JSON.parse(JSON.stringify(x)) loses undefined, turns Date into a string and breaks on BigInt.
In an interview: sort() without a comparator sorts as strings and mutates the array, so I pass a comparator or use toSorted. Months in Date are zero-based, and I fix time zones on CI. Spread makes only a shallow copy, and for a deep one I take structuredClone.