Skip to content

Kiểu dữ liệu & so sánh ​

Phỏng vấn JavaScript hay có những câu như [] == ![] ra gì, vì sao 0.1 + 0.2 !== 0.3, typeof null là gì. Những câu này không để đánh đố, mà để kiểm tra mình có hiểu cách JavaScript lưu và so sánh giá trị không, vì chính những chỗ đó gây ra bug thật ở backend: tiền tính sai vài đồng, id 64 bit bị làm tròn, cấu hình 0 bị thay bằng giá trị mặc định. Trang này đi qua các kiểu dữ liệu, các phép so sánh, kiểu số, cách sao chép object và các toán tử dùng với giá trị rỗng.

Bảy kiểu nguyên thuỷ và object ​

JavaScript có 7 kiểu nguyên thuỷ (primitive) và object:

KiểuVí dụtypeof
undefinedbiến chưa gán, thuộc tính không có"undefined"
null"không có giá trị", gán có chủ đích"object"
booleantrue, false"boolean"
number42, 3.14, NaN, Infinity"number"
bigint10n"bigint"
string'an'"string"
symbolSymbol('id')"symbol"
object{}, [], new Date(), hàm"object", riêng hàm là "function"

Giá trị nguyên thuỷ không sửa được: mọi phép "sửa" chuỗi đều tạo chuỗi mới. Object thì sửa được, và biến chỉ giữ tham chiếu tới object.

Hai kết quả hay bị hỏi:

  • typeof null === "object" là một lỗi có từ phiên bản JavaScript đầu tiên. Tài liệu MDN giải thích rằng khi đó mỗi giá trị lưu kèm một thẻ kiểu, object có thẻ là 0, còn null được biểu diễn bằng con trỏ rỗng, cũng là 0. Lỗi này không sửa được vì sẽ làm hỏng code cũ. Muốn kiểm tra null thì so sánh thẳng v === null.
  • typeof NaN === "number": NaN (not a number) là một giá trị của kiểu number, kết quả của phép tính không hợp lệ như 0 / 0 hay Number('abc'). Kiểm tra bằng Number.isNaN(v).

typeof [] cũng là "object", nên kiểm tra mảng bằng Array.isArray.

==, === và Object.is ​

  • === (so sánh nghiêm ngặt): khác kiểu thì trả false ngay, không ép kiểu.
  • == (so sánh lỏng): khác kiểu thì ép về cùng kiểu theo một chuỗi luật rồi mới so. Object được đổi sang giá trị nguyên thuỷ ([] thành '', [0] thành '0'), boolean đổi sang số, chuỗi đổi sang số. Riêng null và undefined chỉ bằng nhau và bằng chính chúng.
  • Object.is: giống === trừ hai trường hợp: coi NaN bằng NaN, và phân biệt 0 với -0.

Từ các luật đó mới có những kết quả trông vô lý. [] == ![] là true: vế phải ![] là false (mảng là truthy), false đổi sang 0, [] đổi sang '' rồi sang 0, và 0 == 0. 0 == '' và 0 == '0' đều đúng, nhưng '' == '0' lại sai, vì hai chuỗi cùng kiểu thì so như chuỗi.

Trỏ vào từng ô để xem vì sao:

Phép so sánh
55 ô bằng nhau trên 196
0-01'''0''1'falsetruenullundefinedNaN[][0]{}
0
-0
1
''
'0'
'1'
false
true
null
undefined
NaN
[]
[0]
{}
bằng nhau, cùng một giá trịbằng nhau dù là hai giá trị khác nhau

Trỏ hoặc bấm vào một ô để xem vì sao. Với == có nhiều ô vàng: giá trị khác nhau nhưng được ép kiểu thành bằng nhau. Với === chỉ còn đường chéo, trừ NaN (không bằng chính nó), 0 và -0 (bằng nhau), và các object (mỗi literal là một object mới).

Mỗi ô được tính bằng chính JavaScript của trình duyệt này.

Quy tắc thực tế là luôn dùng ===, và bật luật eqeqeq của ESLint. Ngoại lệ duy nhất hay được chấp nhận là v == null để kiểm tra cả null lẫn undefined trong một lần.

NaN không bằng chính nó, nên [NaN].indexOf(NaN) trả -1 (dùng ===), còn [NaN].includes(NaN) trả true (dùng thuật toán SameValueZero, coi NaN bằng NaN). Set và Map cũng dùng SameValueZero cho key.

Hai object chỉ bằng nhau khi là cùng một object, với cả ba phép so sánh: {} === {} là false. Muốn so nội dung thì phải tự so từng trường, hoặc dùng assert.deepStrictEqual trong test.

Number là số thực 64 bit ​

Mọi number trong JavaScript là số thực dấu phẩy động 64 bit (IEEE 754 double): 1 bit dấu, 11 bit số mũ, 52 bit phần định trị. Kéo theo hai hệ quả lớn.

Số thập phân không chính xác ​

Hệ nhị phân không biểu diễn chính xác được 0.1, giống như hệ thập phân không viết hết được 1/3. Máy lưu số gần nhất có thể, và khi in ra, JavaScript chọn dạng ngắn nhất đủ để phân biệt số đó, nên 0.1 trông vẫn là 0.1. Sai số chỉ lộ ra khi tính toán: 0.1 + 0.2 là 0.30000000000000004, và 1.005 * 100 là 100.49999999999999, nên Math.round(1.005 * 100) / 100 ra 1 thay vì 1.01.

0.30000000000000004JavaScript in ra (số ngắn nhất đọc lại được đúng giá trị này)

Giá trị thật sự lưu trong 64 bit:

0.3000000000000000444089209850062616169452667236328125
0 01111111101 0011001100110011001100110011001100110011001100110100
dấu (1 bit)số mũ (11 bit): 1021 − 1023 = -2phần định trị (52 bit)

Số này không biểu diễn chính xác được trong hệ nhị phân, giống như 1/3 không viết hết được trong hệ thập phân. Máy lưu số gần nhất có thể, và JavaScript in ra dạng ngắn nhất đủ để phân biệt nó với các số khác, nên trông như đúng. Sai số chỉ lộ ra khi cộng dồn hay so sánh.

Vì vậy không tính tiền bằng số thực:

  • Lưu và tính tiền bằng đơn vị nhỏ nhất dạng số nguyên: đồng, hay xu với tiền có phần lẻ. 1999 * 3 là 5997, đúng tuyệt đối.
  • Trong database dùng DECIMAL/NUMERIC, không dùng FLOAT/DOUBLE (Tiền & sổ cái). Driver thường trả DECIMAL về dạng chuỗi chính vì lý do này (Node.js với database).
  • Cần tính phần trăm, chia tiền có số lẻ thì dùng thư viện số thập phân (decimal.js, big.js), và quy định rõ cách làm tròn.
  • So sánh hai số thực thì so độ chênh với một sai số cho phép, không dùng ===.

Số nguyên chỉ chính xác tới 2⁵³ ​

52 bit phần định trị (cộng một bit ngầm) nghĩa là số nguyên chỉ chính xác tới Number.MAX_SAFE_INTEGER = 2⁵³ − 1 = 9.007.199.254.740.991. Vượt quá thì các số liền nhau không còn phân biệt được: 2 ** 53 === 2 ** 53 + 1 là true, và 9007199254740993 viết trong code thành 9007199254740992.

Chuyện này xảy ra thật với id 64 bit, như id Snowflake hay cột BIGINT (Sinh ID). JSON.parse('{"id":1234567890123456789}') âm thầm biến id thành 1234567890123456800, không có lỗi nào. Vì vậy:

  • API trả id lớn dưới dạng chuỗi trong JSON ("id": "1234567890123456789"). Twitter, Discord làm đúng như vậy.
  • Tính toán với số lớn thì dùng BigInt (10n, BigInt('…')). BigInt không trộn được với number (1n + 1 báo TypeError), chia thì làm tròn xuống (10n / 3n là 3n), và JSON.stringify báo lỗi với BigInt, nên phải tự đổi sang chuỗi khi trả về.

Chuỗi sang số ​

Biểu thứcKết quảGhi chú
Number('12px')NaNChuỗi phải là số hoàn chỉnh
parseFloat('12px')12Đọc tới chỗ không còn là số
parseInt('1e3')1Không hiểu ký hiệu khoa học
Number('')0Chuỗi rỗng thành 0, dễ gây bug khi đọc form
Number(' 12 ')12Bỏ khoảng trắng hai đầu

Đọc số từ query string hay form thì kiểm tra rõ ràng (Number.isInteger, hay dùng thư viện validate như zod), vì Number('') âm thầm ra 0.

Tham chiếu, shallow copy và deep copy ​

Gán object cho biến khác hay truyền vào hàm là truyền tham chiếu: hai biến cùng trỏ tới một object, sửa qua biến này thì thấy qua biến kia. Muốn có bản riêng thì phải sao chép:

CáchSao chépGiới hạn
{ ...obj }, Object.assign, [...arr]Nông: chỉ cấp ngoài cùng; object lồng bên trong vẫn dùng chungSửa copy.items[0].qty làm đổi cả bản gốc
JSON.parse(JSON.stringify(obj))SâuDate thành chuỗi, mất undefined, hàm, Map, Set; lỗi với vòng tham chiếu; BigInt báo lỗi
structuredClone(obj) (Node 17+)SâuGiữ Date, Map, Set, vòng tham chiếu; báo lỗi với hàm và class instance mất prototype

Với backend, sao chép nông là đủ trong đa số trường hợp, miễn là không sửa object lồng bên trong. Khi cần một bản sao độc lập, ví dụ trước khi sửa một object cấu hình dùng chung, thì dùng structuredClone.

Truthy, falsy, ?? và || ​

Có đúng 8 giá trị falsy: false, 0, -0, 0n, '', null, undefined, NaN. Mọi giá trị khác là truthy, kể cả '0', [], {}.

|| trả vế phải khi vế trái là falsy, còn ?? (nullish coalescing) chỉ trả vế phải khi vế trái là null hoặc undefined. Khác biệt này gây bug với giá trị hợp lệ mà falsy:

js
const cfg = { retries: 0, name: '' }
cfg.retries || 3   // 3: người dùng cấu hình 0 lần retry, nhưng bị thay bằng 3
cfg.retries ?? 3   // 0: đúng ý
cfg.name || 'khách' // 'khách'
cfg.name ?? 'khách' // ''

Dùng ?? cho giá trị mặc định, trừ khi thật sự muốn coi 0 và '' là "không có". Tương tự, ??= gán giá trị mặc định chỉ khi biến là null hoặc undefined.

Optional chaining a?.b?.c trả undefined khi gặp null hay undefined giữa chừng, thay vì báo TypeError: Cannot read properties of undefined. Nên dùng cho dữ liệu thật sự có thể thiếu (trường tuỳ chọn trong request). Đừng rải ?. khắp nơi, vì nó che mất bug khi dữ liệu lẽ ra phải có.

Ghi nhớ

  1. typeof null là "object" (lỗi lịch sử), typeof NaN là "number"; kiểm tra bằng === null, Number.isNaN, Array.isArray.
  2. Luôn dùng ===. == ép kiểu theo chuỗi luật dễ nhầm; Object.is chỉ khác === ở NaN và -0. Object chỉ bằng nhau khi là cùng một object.
  3. Number là double 64 bit: số thập phân không chính xác, nên tính tiền bằng số nguyên đơn vị nhỏ nhất hoặc thư viện decimal.
  4. Số nguyên chỉ chính xác tới 2⁵³ − 1: trả id 64 bit dạng chuỗi, tính bằng BigInt.
  5. Spread là sao chép nông; structuredClone là sao chép sâu. Dùng ?? thay || cho giá trị mặc định để không mất 0 và ''.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-26):

NguồnDùng cho phần
MDN, typeofLý do typeof null === "object": thẻ kiểu của object là 0, null là con trỏ rỗng
MDN, structuredClone()Báo DataCloneError khi có phần không sao chép được
MDN, Nullish coalescing (??)Chỉ trả vế phải khi vế trái là null hoặc undefined

Kiểm bằng lab: mọi kết quả trong trang (chạy trên Node 24). Từ kiến thức chung, chưa đối chiếu lần này: chuỗi luật IsLooselyEqual và SameValueZero trong đặc tả ECMAScript; IEEE 754 double; Twitter, Discord trả id dạng chuỗi.

Số đo chi tiết ​

Kết quả chạy lab/kieu-so-sanh/types.mjs (Node 24.14)
Biểu thứcKết quả
0 == "", 0 == "0", "" == "0"true, true, false
null == undefined, null == 0, null >= 0true, false, true
[] == false, [] == ![]true, true
"1" + 1, "1" - 1"11", 0
[NaN].includes(NaN), [NaN].indexOf(NaN)true, -1
0.1 + 0.2, 1.005 * 100, (1.005).toFixed(2)0.30000000000000004, 100.49999999999999, "1.00"
JSON.parse('{"id":1234567890123456789}').id1234567890123456800
1n + 1, JSON.stringify({ id: 1n })TypeError cả hai
JSON.parse(JSON.stringify({ d: new Date() })).dchuỗi ISO, mất kiểu Date
structuredClone({ f() {} })DOMException: … could not be cloned
{ retries: 0 }.retries || 3, … ?? 33, 0

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- kieu-so-sanh, chỉ cần Node.js. Xem Lab.

lab/kieu-so-sanh/types.mjs
js
// Trang nodejs/kieu-so-sanh: in kết quả thật của các phép so sánh và phép tính hay bị hỏi.
const show = (label, fn) => {
  let v
  try { v = fn() } catch (e) { v = `LỖI ${e.constructor.name}: ${e.message}` }
  const s = typeof v === 'string' && !v.startsWith('LỖI') ? JSON.stringify(v) : typeof v === 'bigint' ? `${v}n` : String(v)
  console.log(`${label.padEnd(46)} ${s}`)
}

console.log('\n=== typeof ===')
for (const [l, f] of [['typeof null', () => typeof null], ['typeof undefined', () => typeof undefined], ['typeof NaN', () => typeof NaN],
  ['typeof []', () => typeof []], ['typeof function () {}', () => typeof function () {}], ['typeof 10n', () => typeof 10n],
  ['typeof Symbol()', () => typeof Symbol()], ['Array.isArray([])', () => Array.isArray([])]]) show(l, f)

console.log('\n=== == ép kiểu, === không ép, Object.is ===')
for (const [l, f] of [['0 == ""', () => 0 == ''], ['0 == "0"', () => 0 == '0'], ['"" == "0"', () => '' == '0'],
  ['null == undefined', () => null == undefined], ['null == 0', () => null == 0], ['null >= 0', () => null >= 0],
  ['[] == false', () => [] == false], ['[] == ![]', () => [] == ![]], ['"1" + 1', () => '1' + 1], ['"1" - 1', () => '1' - 1],
  ['NaN === NaN', () => NaN === NaN], ['Object.is(NaN, NaN)', () => Object.is(NaN, NaN)], ['0 === -0', () => 0 === -0],
  ['Object.is(0, -0)', () => Object.is(0, -0)], ['[NaN].includes(NaN)', () => [NaN].includes(NaN)], ['[NaN].indexOf(NaN)', () => [NaN].indexOf(NaN)]]) show(l, f)

console.log('\n=== Number là số thực 64 bit ===')
for (const [l, f] of [['0.1 + 0.2', () => 0.1 + 0.2], ['0.1 + 0.2 === 0.3', () => 0.1 + 0.2 === 0.3],
  ['Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON', () => Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON],
  ['1.005 * 100', () => 1.005 * 100], ['Math.round(1.005 * 100) / 100', () => Math.round(1.005 * 100) / 100],
  ['(1.005).toFixed(2)', () => (1.005).toFixed(2)], ['19.99 * 3', () => 19.99 * 3], ['1999 * 3 (tính bằng đồng/xu)', () => 1999 * 3],
  ['Number.MAX_SAFE_INTEGER', () => Number.MAX_SAFE_INTEGER], ['2 ** 53 === 2 ** 53 + 1', () => 2 ** 53 === 2 ** 53 + 1],
  ['9007199254740993 (viết trong code)', () => 9007199254740993],
  ['JSON.parse(\'{"id":1234567890123456789}\').id', () => JSON.parse('{"id":1234567890123456789}').id],
  ['BigInt("1234567890123456789") + 1n', () => BigInt('1234567890123456789') + 1n], ['10n / 3n', () => 10n / 3n],
  ['1n + 1', () => 1n + 1], ['JSON.stringify({ id: 1n })', () => JSON.stringify({ id: 1n })],
  ['0.1 * 3', () => 0.1 * 3], ['parseInt("08")', () => parseInt('08')], ['parseInt("1e3")', () => parseInt('1e3')], ['Number("1e3")', () => Number('1e3')],
  ['Number("")', () => Number('')], ['Number(" 12 ")', () => Number(' 12 ')], ['Number("12px")', () => Number('12px')], ['parseFloat("12px")', () => parseFloat('12px')]]) show(l, f)

console.log('\n=== Tham chiếu, shallow copy, deep copy ===')
const order = { id: 1, items: [{ sku: 'A', qty: 1 }], createdAt: new Date('2025-01-01T00:00:00Z') }
const spread = { ...order }
spread.items[0].qty = 99
show('Sửa spread.items[0].qty → order.items[0].qty', () => order.items[0].qty)
order.items[0].qty = 1
const viaJson = JSON.parse(JSON.stringify(order))
show('JSON.parse(JSON.stringify(x)).createdAt là', () => typeof viaJson.createdAt + ' ' + JSON.stringify(viaJson.createdAt))
const cloned = structuredClone(order)
cloned.items[0].qty = 5
show('structuredClone rồi sửa → order.items[0].qty', () => order.items[0].qty)
show('structuredClone giữ Date', () => cloned.createdAt instanceof Date)
show('structuredClone({ f() {} })', () => structuredClone({ f() {} }))
show('structuredClone(new Map([[1, 2]])).get(1)', () => structuredClone(new Map([[1, 2]])).get(1))
const cyc = { name: 'a' }; cyc.self = cyc
show('JSON.stringify với vòng tham chiếu', () => JSON.stringify(cyc))
show('structuredClone với vòng tham chiếu', () => { const c = structuredClone(cyc); return c.self === c })

console.log('\n=== Truthy, falsy, ?? và || ===')
const falsy = [false, 0, -0, 0n, '', null, undefined, NaN]
show('Các giá trị falsy', () => falsy.map((v) => (typeof v === 'bigint' ? '0n' : Object.is(v, -0) ? '-0' : v === '' ? "''" : String(v))).join(', '))
show('Boolean("0"), Boolean([]), Boolean({})', () => [Boolean('0'), Boolean([]), Boolean({})].join(', '))
const cfg = { retries: 0, name: '' }
show('cfg.retries || 3', () => cfg.retries || 3)
show('cfg.retries ?? 3', () => cfg.retries ?? 3)
show('cfg.name || "khách"', () => cfg.name || 'khách')
show('cfg.name ?? "khách"', () => cfg.name ?? 'khách')
show('cfg.user?.address?.city', () => cfg.user?.address?.city)
show('cfg.user.address', () => cfg.user.address)