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:

  1. 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.
  2. Know the number of minor units for the currency: most have 2, but JPY has 0, and some currencies have 3 (for example KWD).
  3. 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.
  4. 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.