Lỗi 'toFixed is not a function' trong React do MySQL trả DECIMAL thành string


Lỗi ‘toFixed is not a function’ trong React do MySQL trả DECIMAL thành string

Một buổi tối trước deadline, khách hàng nhắn tin báo trang hóa đơn bị trắng trên điện thoại. Mở console lên thì thấy ngay:

TypeError: (t.tax_rate ?? 0).toFixed is not a function

Kỳ lạ ở chỗ — mình test trên trình duyệt desktop với tài khoản admin thì không có lỗi gì. Chỉ role customer trên mobile mới bị. Mất gần một tiếng mới tìm ra nguyên nhân, và lý do thì đơn giản đến bực mình.

Lỗi xảy ra như thế nào

Đoạn code đang render invoice trong React trông như thế này:

<td>{(invoice.tax_rate ?? 0).toFixed(0)}%</td>
<td>{Number((invoice.subtotal ?? 0).toFixed(0)).toLocaleString()} đ</td>

Logic tưởng chừng ổn: nếu tax_ratenull hoặc undefined thì dùng 0, rồi gọi .toFixed(0) để làm tròn. Thế nhưng chạy thực tế thì TypeError văng ra ngay dòng đầu tiên.

Nguyên nhân gốc rễ

MySQL và MariaDB lưu kiểu DECIMAL với độ chính xác cao — đây là kiểu số dạng chuỗi bên trong database. Khi dữ liệu đi qua ORM (SQLAlchemy, Sequelize…) và được serialize ra JSON response, giá trị DECIMAL bị chuyển thành string:

{
  "tax_rate": "10.00",
  "subtotal": "1500000.00"
}

Lúc này JavaScript nhận về "10.00" — một string, không phải số. Toán tử ?? (nullish coalescing) chỉ kiểm tra null hoặc undefined, không ép kiểu. Vì vậy:

"10.00" ?? 0   // → "10.00"  (string, không phải null/undefined nên giữ nguyên)
"10.00".toFixed(0)  // → TypeError: toFixed is not a function

String không có method .toFixed() — đó là method của Number. Lỗi hoàn toàn hợp lý.

Tại sao admin không bị? Trang admin đã vô tình wrap Number() ở một chỗ khác trong component — một cái fix cũ mà không ai nhớ lý do. Trang customer thì không có, nên bị lỗi ngay.

Cách tái hiện lỗi

Chỉ cần vài dòng để reproduce:

const taxRate = "10.00" // Đây là giá trị thực MySQL DECIMAL trả về qua JSON

console.log(typeof taxRate)            // "string"
console.log((taxRate ?? 0).toFixed(0)) // TypeError: toFixed is not a function

So sánh với trường hợp đúng:

const taxRateNum = 10.00 // Nếu là number thật

console.log((taxRateNum ?? 0).toFixed(0)) // "10" ✅

Cách sửa

Giải pháp đơn giản: bọc Number() trước khi gọi .toFixed().

// ❌ Sai — giả định tax_rate là number
(invoice.tax_rate ?? 0).toFixed(0)

// ✅ Đúng — ép kiểu trước
Number(invoice.tax_rate ?? 0).toFixed(0)   // "10"
Number(invoice.subtotal ?? 0).toFixed(0)   // "1500000"

Number("10.00") trả về 10 — một number thật, có đầy đủ các method số học.

Nếu trong project có nhiều chỗ format số tiền, tốt nhất là tạo một helper dùng chung để tránh lặp lại và dễ test:

// utils/format.js
export const fmt = (val, digits = 0) => Number(val ?? 0).toFixed(digits)
export const fmtMoney = (val) => Number(val ?? 0).toLocaleString('vi-VN') + ' đ'

Dùng trong component:

import { fmt, fmtMoney } from '@/utils/format'

<td>{fmt(invoice.tax_rate)}%</td>
<td>{fmtMoney(invoice.subtotal)}</td>

Cách này còn có lợi là khi muốn thay đổi cách format (thêm locale, đổi đơn vị tiền), chỉ sửa một chỗ.

Bài học

  • DECIMAL trong MySQL/MariaDB luôn về string qua JSON — đừng bao giờ assume nó là number ở phía JavaScript. Kiểm tra bằng typeof nếu không chắc.
  • ?? 0 không ép kiểu — nó chỉ thay thế null/undefined, không chuyển "10.00" thành 10. Dùng Number() hoặc parseFloat() trước khi thực hiện phép tính số.
  • Viết test với giá trị string để bắt lỗi ngay từ đầu thay vì chờ khách hàng báo:
test('fmt xử lý được string từ MySQL', () => {
  expect(fmt("10.00")).toBe("10")
  expect(fmt(null)).toBe("0")
  expect(fmt(undefined)).toBe("0")
})

Lỗi nhỏ, nguyên nhân đơn giản — nhưng mất thời gian debug không ít vì nó chỉ xuất hiện ở một role cụ thể, trên mobile, với dữ liệu thật từ production. Khi gặp kiểu lỗi is not a function với method số học trong React, điều đầu tiên nên kiểm tra là kiểu dữ liệu thật sự đến từ API — đừng tin vào TypeScript types nếu chúng không được validate kỹ ở tầng API.