Giao diện
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ểu | Ví dụ | typeof |
|---|---|---|
undefined | biế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" |
boolean | true, false | "boolean" |
number | 42, 3.14, NaN, Infinity | "number" |
bigint | 10n | "bigint" |
string | 'an' | "string" |
symbol | Symbol('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ònnullđượ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ẳngv === 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 / 0hayNumber('abc'). Kiểm tra bằngNumber.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ảfalsengay, 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êngnullvàundefinedchỉ bằng nhau và bằng chính chúng.Object.is: giống===trừ hai trường hợp: coiNaNbằngNaN, và phân biệt0vớ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ánh55 ô bằng nhau trên 196
| 0 | -0 | 1 | '' | '0' | '1' | false | true | null | undefined | NaN | [] | [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 * 3là5997, đúng tuyệt đối. - Trong database dùng
DECIMAL/NUMERIC, không dùngFLOAT/DOUBLE(Tiền & sổ cái). Driver thường trảDECIMALvề 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 + 1báoTypeError), chia thì làm tròn xuống (10n / 3nlà3n), vàJSON.stringifybá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ức | Kết quả | Ghi chú |
|---|---|---|
Number('12px') | NaN | Chuỗi phải là số hoàn chỉnh |
parseFloat('12px') | 12 | Đọc tới chỗ không còn là số |
parseInt('1e3') | 1 | Không hiểu ký hiệu khoa học |
Number('') | 0 | Chuỗi rỗng thành 0, dễ gây bug khi đọc form |
Number(' 12 ') | 12 | Bỏ 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ách | Sao chép | Giớ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 chung | Sửa copy.items[0].qty làm đổi cả bản gốc |
JSON.parse(JSON.stringify(obj)) | Sâu | Date 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âu | Giữ 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ớ
typeof nulllà"object"(lỗi lịch sử),typeof NaNlà"number"; kiểm tra bằng=== null,Number.isNaN,Array.isArray.- Luôn dùng
===.==ép kiểu theo chuỗi luật dễ nhầm;Object.ischỉ khác===ở NaN và -0. Object chỉ bằng nhau khi là cùng một object. - 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.
- 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.
- Spread là sao chép nông;
structuredClonelà sao chép sâu. Dùng??thay||cho giá trị mặc định để không mất0và''.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-26):
| Nguồn | Dùng cho phần |
|---|---|
| MDN, typeof | Lý 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ức | Kết quả |
|---|---|
0 == "", 0 == "0", "" == "0" | true, true, false |
null == undefined, null == 0, null >= 0 | true, 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}').id | 1234567890123456800 |
1n + 1, JSON.stringify({ id: 1n }) | TypeError cả hai |
JSON.parse(JSON.stringify({ d: new Date() })).d | chuỗi ISO, mất kiểu Date |
structuredClone({ f() {} }) | DOMException: … could not be cloned |
{ retries: 0 }.retries || 3, … ?? 3 | 3, 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)