Lỗi hay gặp với số, ngày giờ và chuỗi trong Node.js + PostgreSQL Trung cấp
Thời gian đọc ước tính: 9 phút
Trang này trả lời: vì sao số tiền lệch 1 đồng, id sai một đơn vị, báo cáo lệch một ngày. Thủ phạm thường không phải logic nghiệp vụ mà là kiểu dữ liệu đi qua ranh giới JS ↔ JSON ↔ PostgreSQL.
Output trong comment chạy thật trên Node 24.17, pg 8.23 và PostgreSQL 17, app ở TZ=Asia/Ho_Chi_Minh trừ khi ghi khác.
Kiểu dữ liệu đổi hình khi qua ranh giới
Phần tiêu đề “Kiểu dữ liệu đổi hình khi qua ranh giới”| PostgreSQL | node-postgres trả về | Bẫy |
|---|---|---|
bigint, count(*) |
string |
"1" + 1 === "11" |
numeric |
string |
"12.50" + 1 === "12.501" |
date |
Date lúc 00:00 giờ của process |
JSON thành 2026-10-17T17:00:00.000Z |
timestamp |
Date hiểu theo giờ của process |
Hai server khác TZ đọc ra hai thời điểm |
timestamptz |
Date đúng thời điểm |
An toàn |
Tính tiền bằng float
Hay gặp
const total = items.reduce((sum, i) => sum + i.price, 0);// 0.1 + 0.2 -> 0.30000000000000004// cộng 0.1 mười lần -> 0.9999999999999999// (1.005).toFixed(2) -> "1.00" (mong đợi "1.01")Nên viết
// Lưu và tính bằng số nguyên đơn vị nhỏ nhất (cent, hoặc đồng với VND)const totalCents = items.reduce((sum, i) => sum + i.priceCents, 0);// DB: amount_cents bigint, hoặc numeric(14, 2) và để PostgreSQL tính SUMVì sao
number là float nhị phân 64-bit, không biểu diễn chính xác 0.1. Sai số nhỏ cộng dồn qua nhiều dòng, làm tròn sai, và so sánh === với tổng từ DB thất bại. Đối soát cuối ngày lệch vài đồng là triệu chứng điển hình.
Tin rằng count, bigint, numeric là number
Hay gặp
const { rows } = await pool.query( 'select count(*) as n, 12.50::numeric(12,2) as price',);rows[0].n + 1; // "11" (n là "1")rows[0].price + 1; // "12.501"Nên viết
// Cách 1: ép kiểu ngay trong SQL khi chắc chắn vừa int4await pool.query('select count(*)::int as n'); // { n: 1 }
// Cách 2: đăng ký parser một lần lúc khởi độngimport pg from 'pg';pg.types.setTypeParser(pg.types.builtins.INT8, (v) => BigInt(v));// count(*) -> 1n. numeric: giữ string, tính bằng thư viện decimal hoặc trong SQL.Vì sao
node-postgres trả string cho kiểu không có parser số an toàn: int8 có thể vượt Number.MAX_SAFE_INTEGER, numeric có thể có độ chính xác tuỳ ý. Toán tử + với string là nối chuỗi, nên lỗi không ném exception mà ra số sai. TypeScript không cứu được vì rows mặc định là any.
Id bigint vượt 2^53 thành number
Hay gặp
JSON.parse('{"id":9007199254740993}').id; // 9007199254740992Number('9007199254740993'); // 9007199254740992// Gọi GET /orders/9007199254740992 -> 404, hoặc tệ hơn: trúng order khácNên viết
// Id bigint đi qua API dưới dạng string; tính toán thì dùng BigInttype OrderDto = { id: string };BigInt('9007199254740993') + 1n; // 9007199254740994nNumber.isSafeInteger(2 ** 53 + 1); // false -> dùng để chặn ở validationVì sao
Số nguyên chỉ chính xác tới Number.MAX_SAFE_INTEGER = 9007199254740991. Id snowflake, id từ hệ thống khác hay sequence chạy lâu đều có thể vượt. Ép sang number ở bất kỳ đâu (kể cả JSON.parse phía client) làm id âm thầm đổi giá trị.
parseInt, Number('') cho query string
Hay gặp
const page = Number(req.query.page); // ?page= -> 0const limit = parseInt(req.query.limit); // ?limit=10abc -> 10['1', '2', '3'].map(parseInt); // [ 1, NaN, NaN ]// Number(''), Number(' '), Number(null) đều là 0Nên viết
function parsePositiveInt(raw: unknown, fallback: number, max: number): number { if (raw === undefined) return fallback; if (typeof raw !== 'string' || !/^\d+$/.test(raw)) throw new BadRequestException('Số không hợp lệ'); const n = Number(raw); if (n < 1 || n > max) throw new BadRequestException(`Phải trong 1..${max}`); return n;}// NestJS: ParseIntPipe hoặc DTO + class-validator thay cho hàm tự viết.Vì sao
parseIntđọc tới ký tự không phải số rồi dừng:"10px"→ 10,"1e3"→ 1.map(parseInt)truyền index làm radix, nên raNaN.Number('')là0, không phảiNaN: tham số rỗng lọt qua kiểm traisNaNvà thànhOFFSET 0hoặcLIMIT 0.
Dùng || để gán mặc định
Hay gặp
const limit = dto.limit || 20; // limit: 0 -> 20const name = dto.displayName || 'Ẩn danh'; // '' -> 'Ẩn danh'const discount = rule.percent || 10; // 0% -> 10% (!)Nên viết
const limit = dto.limit ?? 20; // 0 -> 0const name = dto.displayName ?? 'Ẩn danh'; // '' -> ''const discount = rule.percent ?? 10; // 0 -> 0Vì sao
|| thay mọi giá trị falsy (0, '', false, NaN), còn ?? chỉ thay null/undefined. Với cờ boolean và số tiền, || biến “cố ý bằng 0/false” thành giá trị mặc định: giảm giá 0% thành 10%, isActive: false thành true.
Ngày giờ
Phần tiêu đề “Ngày giờ”new Date('YYYY-MM-DD') để lọc theo ngày
Hay gặp
// ?from=2026-10-18 — "lấy đơn từ đầu ngày 18/10"const from = new Date(req.query.from);from.getHours(); // 7 (TZ Asia/Ho_Chi_Minh) — mất đơn từ 00:00 tới 07:00// Server ở America/New_York: from.getDate() -> 17Nên viết
// Ghi rõ múi giờ nghiệp vụ, không phụ thuộc TZ của serverconst from = new Date(`${req.query.from}T00:00:00+07:00`);from.toISOString(); // 2026-10-17T17:00:00.000Z = 00:00 giờ Việt Nam// Hoặc đẩy cho PostgreSQL: where created_at >= ($1::date)::timestamp at time zone 'Asia/Ho_Chi_Minh'Vì sao
Chuỗi chỉ có ngày được JS hiểu là UTC nửa đêm; chuỗi ngày-giờ không có offset (2026-10-18T00:00) lại hiểu là giờ local. Cùng code cho kết quả khác nhau giữa laptop dev, container TZ=UTC và server ở vùng khác.
Cột timestamp (không timezone) cho thời điểm sự kiện
Hay gặp
create table orders (id bigint, created_at timestamp);-- Cùng một Date 2026-10-18T02:00:00Z được insert từ hai pod:-- pod TZ=Asia/Ho_Chi_Minh -> lưu '2026-10-18 09:00:00'-- pod TZ=UTC -> lưu '2026-10-18 02:00:00'Nên viết
create table orders (id bigint, created_at timestamptz not null default now());-- Cả hai pod -> '2026-10-18 02:00:00+00' (cùng một thời điểm)Vì sao
timestamp without time zone bỏ qua offset khi lưu. node-postgres gửi Date theo giờ local của process, nên giá trị lưu phụ thuộc TZ của pod. timestamptz quy đổi về UTC khi lưu, đúng thứ cần cho “sự kiện xảy ra lúc nào”. Chỉ dùng timestamp cho giờ “trên đồng hồ treo tường” như giờ mở cửa.
Cột date đi qua node-postgres thành Date
Hay gặp
const { rows } = await pool.query("select '2026-10-18'::date as birthday");res.json(rows[0]);// {"birthday":"2026-10-17T17:00:00.000Z"} — client hiển thị 17/10Nên viết
import pg from 'pg';pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v); // giữ nguyên chuỗi// -> {"birthday":"2026-10-18"}// Hoặc trong SQL: select birthday::textVì sao
date không có giờ, nhưng node-postgres tạo Date lúc 00:00 giờ local của process. Ở UTC+7, toJSON() trừ 7 tiếng và chuỗi ISO lùi sang ngày hôm trước. Ngày sinh, ngày hết hạn hiển thị sai một ngày mà DB vẫn đúng.
Mảng, object, JSON
Phần tiêu đề “Mảng, object, JSON”sort() không truyền hàm so sánh
Hay gặp
[10, 9, 1, 100].sort(); // [ 1, 10, 100, 9 ]['b', 'a', 'Á', 'á', 'C'].sort(); // [ 'C', 'a', 'b', 'Á', 'á' ]const top = users.sort(byScore); // đồng thời đảo thứ tự mảng users gốcNên viết
[10, 9, 1, 100].sort((a, b) => a - b); // [ 1, 9, 10, 100 ]names.sort(new Intl.Collator('vi').compare); // [ 'a', 'á', 'Á', 'b', 'C' ]const top = users.toSorted(byScore); // không đụng mảng gốcVì sao
Mặc định sort đổi mọi phần tử sang string rồi so theo code unit UTF-16, nên số và tiếng Việt có dấu sai thứ tự. sort còn sửa mảng tại chỗ: sort một mảng lấy từ cache là sửa luôn cache cho request sau. toSorted (ES2023) trả mảng mới.
Spread nông rồi sửa object lồng nhau
Hay gặp
const DEFAULTS = { retry: { attempts: 3 }, tags: ['base'] };function buildConfig(o: { attempts: number; tag: string }) { const c = { ...DEFAULTS }; c.retry.attempts = o.attempts; // sửa object dùng chung c.tags.push(o.tag); return c;}buildConfig({ attempts: 10, tag: 'a' });// DEFAULTS -> {"retry":{"attempts":10},"tags":["base","a"]}Nên viết
function buildConfig(o: { attempts: number; tag: string }) { const c = structuredClone(DEFAULTS); // copy sâu c.retry.attempts = o.attempts; c.tags.push(o.tag); return c;}// DEFAULTS giữ nguyên {"retry":{"attempts":3},"tags":["base"]}Vì sao
Spread { ...obj } chỉ copy một tầng; object và mảng bên trong vẫn là cùng tham chiếu. Trên server sống lâu, mỗi request sửa dần giá trị mặc định: bug chỉ xuất hiện sau vài giờ chạy và biến mất khi restart.
Tin JSON.stringify giữ nguyên dữ liệu
Hay gặp
JSON.stringify({ a: undefined, b: null, m: new Map([[1, 2]]), n: NaN });// {"b":null,"m":{},"n":null} — mất a, Map rỗng, NaN thành nullJSON.stringify({ id: 1n });// TypeError: Do not know how to serialize a BigIntNên viết
const body = JSON.stringify( { id: 1n, tags: Object.fromEntries(map) }, (_key, v) => (typeof v === 'bigint' ? v.toString() : v),);// {"id":"1", ...}// PATCH: dùng null cho "xoá giá trị", vì undefined sẽ biến mất khỏi payload.Vì sao
undefined, function bị bỏ key;Map/Setthành{};NaN/Infinitythànhnull.BigIntném lỗi: bật parserINT8 → BigIntmà quên serializer là mọi response cócountđều 500.
Kiểm tra độ dài chuỗi bằng .length
Hay gặp
'👍'.length; // 2if (name.length > 5) throw new BadRequestException(); // 'abcd👍' bị chặn (length 6)'Hello 👍'.slice(0, 7); // "Hello \ud83d" — cắt đôi emoji, ra ký tự hỏngNên viết
[...'abcd👍'].length; // 5 — đếm code point, khớp length() của PostgreSQLconst seg = new Intl.Segmenter('vi', { granularity: 'grapheme' });[...seg.segment('👨👩👧')].length; // 1 — đúng số "ký tự" người dùng thấy// Chuẩn hoá trước khi so sánh/lưu unique: s.normalize('NFC')Vì sao
.lengthđếm UTF-16 code unit; emoji chiếm 2. PostgreSQLvarchar(5)đếm ký tự nên nhận'abcd👍'mà API lại từ chối.- Tiếng Việt có hai dạng:
'Việt'NFC dài 4, NFD dài 6, và'é' === 'é'làfalse. Khôngnormalize('NFC')thì unique username có thể bị lách.
Checklist nhanh
Phần tiêu đề “Checklist nhanh”- Tiền: số nguyên đơn vị nhỏ nhất hoặc
numeric, không float. - Đã quyết định cách parse
int8/numeric/datecủa node-postgres, áp dụng toàn app. - Id bigint đi qua API dạng string.
- Query param số được validate chặt (regex/ParseIntPipe/DTO), không
parseInttrần. - Giá trị mặc định dùng
??, không||. - Mọi chuỗi ngày đều có offset rõ ràng; cột thời điểm là
timestamptz. -
sortcó hàm so sánh; dữ liệu dùng chung không bị sort/spread rồi sửa. - Serializer xử lý
BigInt; PATCH phân biệtnullvà thiếu key. - Giới hạn độ dài khớp với cách PostgreSQL đếm; chuỗi người dùng nhập được
normalize('NFC').
Tiếp theo: Database.