Error handling trong TypeScript và Node.js: phân loại lỗi, custom error, Result type và xử lý ở boundary
Bộ khung error handling cho API Node.js/TypeScript: tách lỗi vận hành và bug, custom error có code và cause, middleware Express 5 trả problem+json, Result type, retry an toàn và error boundary trong Next.js.
Ba giờ sáng, orders-api restart liên tục. Log chỉ có đúng một dòng Error: Payment failed: không có stack của lỗi gốc, không biết cổng thanh toán timeout hay mình gửi sai payload. Lần mò một hồi mới ra thủ phạm là đoạn catch (e) { throw new Error('Payment failed') }. Lỗi thật (một ECONNRESET từ upstream) bị vứt đi ngay tại chỗ.
Error handling tốt không có nghĩa là bọc try/catch khắp nơi. Nó là vài quyết định nhất quán: lỗi nào là chuyện bình thường của nghiệp vụ, lỗi nào là bug; lỗi được bắt ở đâu, log ở đâu (một lần thôi), và client nhận về cái gì. Bài này đi qua bộ khung mình dùng cho API Node.js + TypeScript và phần frontend React/Next.js, có code cho từng phần.
Phần log ở đây chỉ nói tới mức liên quan tới lỗi (giữ nguyên cause chain). Setup logging và observability đầy đủ (pino, correlation ID, metrics, tracing) nằm ở bài Logging và observability cho Node.js.
TL;DR:
- Lỗi vận hành (input sai, upstream timeout) thì xử lý trong flow. Bug thì fail nhanh, log đủ ngữ cảnh rồi sửa code.
- Mỗi lỗi có
codecho máy đọc vàcausetrỏ về lỗi gốc. Đừng bao giờ wrap mà làm mấtcause. - Express 5 tự chuyển rejection của async handler vào error middleware. Có đúng một middleware map lỗi sang HTTP status và
application/problem+json(RFC 9457). unhandledRejection/uncaughtException: log rồi thoát, để orchestrator restart.- Lỗi nghiệp vụ dự đoán được thì trả
Result<T, E>, lỗi bất ngờ thì throw.
Hai loại lỗi: operational error và programmer error
Operational error là thứ chắc chắn sẽ xảy ra khi hệ thống chạy thật: user gửi email sai định dạng, đơn hàng không tồn tại, cổng thanh toán trả 503, connection tới Postgres bị reset, token hết hạn. Code không sai. Môi trường hoặc input không như ý, và mình biết trước nó sẽ xảy ra.
Programmer error là bug: đọc property của undefined, gọi hàm với sai kiểu, vi phạm một invariant mà code giả định luôn đúng. Không có cách "xử lý" đúng cho bug ngoài việc fail nhanh, log đủ ngữ cảnh và sửa code. Cố chạy tiếp với state đã hỏng (ví dụ một đơn đã thanh toán nhưng thiếu paymentId) còn nguy hiểm hơn crash.
| Operational error | Programmer error | |
|---|---|---|
| Ví dụ | 404 order, 422 validation, upstream timeout | TypeError, invariant bị vi phạm |
| Nguyên nhân | input, mạng, dịch vụ khác | code sai |
| Phản hồi | 4xx có ý nghĩa, 503, retry, fallback | 500 chung chung, alert, sửa code |
| Có retry không | đôi khi (timeout, 503) | không bao giờ |
| Log level | info/warn |
error/fatal |
import assert from 'node:assert/strict'
type Order = { id: string; status: 'PENDING' | 'PAID' | 'CANCELLED'; paymentId: string | null }
export function assertOrderConsistent(order: Order): void {
// Programmer error: nếu dòng này nổ thì đó là bug ở chỗ ghi dữ liệu, không phải lỗi của user
if (order.status === 'PAID') {
assert.ok(order.paymentId, `Invariant violated: paid order ${order.id} has no paymentId`)
}
}
Quy tắc mình dùng để phân loại: hỏi "client có làm gì khác đi được khi nhận lỗi này không?". Nếu có (sửa input, đăng nhập lại, thử lại sau) thì đó là operational error và cần một response rõ ràng. Nếu không thì đó là bug: trả 500 chung chung và để alert báo cho mình.
Custom error: một hierarchy nhỏ, có code và cause
Đừng tạo 40 class lỗi. Một base class và vài subclass map thẳng ra HTTP status là đủ cho phần lớn API. Thứ quan trọng nhất là code (string ổn định để client và dashboard filter) và cause (ES2022, có từ Node 16.9) để không bao giờ mất lỗi gốc.
// src/errors.ts
export type ErrorCode =
| 'VALIDATION_FAILED'
| 'NOT_FOUND'
| 'CONFLICT'
| 'UNAUTHENTICATED'
| 'UPSTREAM_UNAVAILABLE'
export abstract class AppError extends Error {
abstract readonly code: ErrorCode
abstract readonly httpStatus: number
/** true: message an toàn để trả về cho client */
readonly expose: boolean = true
constructor(message: string, options?: ErrorOptions) {
super(message, options)
this.name = new.target.name
}
}
export class ValidationError extends AppError {
readonly code = 'VALIDATION_FAILED'
readonly httpStatus = 422
readonly fieldErrors: Record<string, string[]>
constructor(fieldErrors: Record<string, string[]>) {
super('Request body is invalid')
this.fieldErrors = fieldErrors
}
}
export class NotFoundError extends AppError {
readonly code = 'NOT_FOUND'
readonly httpStatus = 404
constructor(resource: string, id: string) {
super(`${resource} ${id} not found`)
}
}
export class ConflictError extends AppError {
readonly code = 'CONFLICT'
readonly httpStatus = 409
}
export class UpstreamError extends AppError {
readonly code = 'UPSTREAM_UNAVAILABLE'
readonly httpStatus = 503
override readonly expose = false
readonly service: string
constructor(service: string, options?: ErrorOptions) {
super(`${service} is unavailable`, options)
this.service = service
}
}
export function isAppError(value: unknown): value is AppError {
return value instanceof AppError
}
/** Chuẩn hoá mọi thứ bị throw (kể cả string, object) thành Error */
export function toError(value: unknown): Error {
if (value instanceof Error) return value
return new Error(`Non-Error thrown: ${String(value)}`, { cause: value })
}
Vài chi tiết đáng để ý:
new.target.namechonameđúng tên subclass mà không phải gán tay ở mỗi class.- Mình không dùng parameter properties (
constructor(readonly service: string)) vàenum. File này vì thế chạy được cả vớitsclẫnnode src/server.tsqua type stripping (stable từ Node 25.2/24.12). Type stripping không hỗ trợ cú pháp cần transform; bậterasableSyntaxOnlytrong tsconfig đểtscbáo lỗi sớm. ErrorOptionscầnlibtừes2022trở lên trong tsconfig.
Khi bọc lỗi từ tầng dưới, luôn truyền cause:
import { UpstreamError } from './errors.js'
type ChargeResult = { paymentId: string }
export async function chargeOrder(orderId: string, amountVnd: number): Promise<ChargeResult> {
try {
const res = await fetch(`${process.env.PAYMENT_API_URL}/charges`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': `order-${orderId}` },
body: JSON.stringify({ orderId, amount: amountVnd, currency: 'VND' }),
signal: AbortSignal.timeout(5_000),
})
if (!res.ok) throw new Error(`payment-gateway responded ${res.status}`)
return (await res.json()) as ChargeResult
} catch (error) {
// Giữ lỗi gốc (TimeoutError, TypeError: fetch failed + cause ECONNRESET...) trong cause
throw new UpstreamError('payment-gateway', { cause: error })
}
}
Narrow unknown trong catch
Từ TypeScript 4.4, useUnknownInCatchVariables (bật sẵn khi có strict) làm biến trong catch có kiểu unknown thay vì any. Đây là điều tốt: JavaScript cho phép throw 'abc', throw { status: 500 }, Promise.reject(undefined), nên error.message không có gì đảm bảo.
import { isAppError, toError } from './errors.js'
try {
await chargeOrder('ord_123', 250_000)
} catch (error) {
if (isAppError(error) && error.code === 'UPSTREAM_UNAVAILABLE') {
// nhánh dự đoán được: đưa đơn vào hàng đợi thanh toán lại
} else {
throw toError(error)
}
}
Edge case: instanceof fail khi có hai bản copy của cùng một package lỗi trong node_modules (monorepo lệch version), hoặc khi lỗi đến từ realm khác (vm, iframe). Nếu gặp, check theo code thay vì class. Với Error nói chung, Error.isError() (có trong Node 24+) kiểm tra được qua realm; nếu lib TypeScript của bạn chưa có type cho nó thì cứ dùng instanceof. Thêm rule @typescript-eslint/only-throw-error để không ai throw string nữa.
Async: floating promise, quên await, Promise.all hay allSettled
Ba lỗi async gây crash nhiều nhất mình từng gặp:
import express from 'express'
const app = express()
declare function createOrder(input: unknown): Promise<{ id: string; email: string }>
declare function sendConfirmationEmail(order: { id: string; email: string }): Promise<void>
declare function refundPayment(orderId: string): Promise<void>
// 1. Floating promise: email lỗi -> unhandledRejection -> process chết (Node 15+)
app.post('/orders', async (req, res) => {
const order = await createOrder(req.body)
sendConfirmationEmail(order) // không await, không catch
res.status(201).json(order)
})
// 2. Quên await trong try: rejection thoát ra ngoài, catch không bao giờ chạy
async function cancelOrder(orderId: string) {
try {
return refundPayment(orderId) // phải là `return await refundPayment(orderId)`
} catch (error) {
throw new Error('Refund failed', { cause: error })
}
}
Bản sửa: việc phụ không được làm hỏng request chính thì vẫn phải có catch, và đánh dấu rõ là cố ý không await. Tốt hơn nữa là đẩy vào job queue để có retry.
void sendConfirmationEmail(order).catch((error: unknown) => {
logger.warn({ err: error, orderId: order.id }, 'confirmation email failed')
})
Đừng trông vào code review để bắt những lỗi này. Rule no-floating-promises của typescript-eslint cần type information và bắt được cả hai trường hợp trên:
// eslint.config.mjs
import eslint from '@eslint/js'
import { defineConfig } from 'eslint/config'
import tseslint from 'typescript-eslint'
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error', // bắt async callback trong forEach, event handler
'@typescript-eslint/only-throw-error': 'error',
'@typescript-eslint/return-await': ['error', 'in-try-catch'], // bắt lỗi số 2
},
},
)
Promise.all và Promise.allSettled không thay thế cho nhau:
Promise.allfail-fast: reject với lỗi đầu tiên. Các promise còn lại vẫn chạy tiếp (không bị huỷ), nên nếu chúng có side effect (ghi DB, gọi API) thì side effect vẫn xảy ra. Dùng khi mọi phần đều bắt buộc, ví dụ loadorder+customer+itemsđể render hoá đơn.Promise.allSettledchờ tất cả và trả trạng thái từng phần. Dùng khi các phần độc lập, ví dụ gửi thông báo cho 200 user hoặc load 5 widget dashboard.
declare function notifyUser(userId: string, message: string): Promise<void>
export async function broadcastMaintenance(userIds: string[], message: string) {
const results = await Promise.allSettled(userIds.map((id) => notifyUser(id, message)))
const failed = results.flatMap((result, i) =>
result.status === 'rejected' ? [{ userId: userIds[i], reason: result.reason as unknown }] : [],
)
return { sent: userIds.length - failed.length, failed }
}
Cái giá cần nhớ: cả hai đều chạy song song không giới hạn. Với 10.000 phần tử, đó là 10.000 request cùng lúc. Hãy chia batch hoặc dùng p-limit.
Express 5: async handler và một error middleware duy nhất
Express 5 xử lý được thứ Express 4 không làm: handler hoặc middleware trả về Promise bị reject (kể cả async function throw) thì Express tự gọi next(err). Không cần express-async-errors hay wrapper asyncHandler nữa. Với code callback-style thì bạn vẫn phải tự gọi next(err).
Mọi lỗi đổ về một chỗ, và chỗ đó map sang Problem Details theo RFC 9457 (thay thế RFC 7807): media type application/problem+json với các field type, title, status, detail, instance, cộng thêm extension tuỳ ý.
// src/app.ts
import express, { type ErrorRequestHandler, type RequestHandler } from 'express'
import { isAppError, NotFoundError, ValidationError } from './errors.js'
import { logger } from './logger.js'
type Order = { id: string; totalVnd: number }
declare function findOrderById(id: string): Promise<Order | null>
export const app = express()
app.use(express.json({ limit: '100kb' }))
app.get('/orders/:id', async (req, res) => {
const order = await findOrderById(req.params.id) // reject -> Express 5 tự gọi next(err)
if (!order) throw new NotFoundError('order', req.params.id)
res.json(order)
})
type Problem = {
type: string
title: string
status: number
detail?: string
instance?: string
code: string
errors?: Record<string, string[]>
}
// Lỗi của body-parser/http-errors có `status` 4xx và `expose: true`
function isClientHttpError(value: unknown): value is { status: number; message: string; expose: boolean } {
if (typeof value !== 'object' || value === null) return false
const status = (value as { status?: unknown }).status
return typeof status === 'number' && status >= 400 && status < 500
}
function toProblem(error: unknown, instance: string): Problem {
if (isAppError(error)) {
return {
type: `/problems/${error.code.toLowerCase().replaceAll('_', '-')}`,
title: error.code,
status: error.httpStatus,
detail: error.expose ? error.message : undefined,
instance,
code: error.code,
...(error instanceof ValidationError ? { errors: error.fieldErrors } : {}),
}
}
if (isClientHttpError(error)) {
return { type: 'about:blank', title: 'Bad Request', status: error.status, detail: error.message, instance, code: 'BAD_REQUEST' }
}
// Bug hoặc lỗi chưa phân loại: không lộ message, stack, câu SQL ra ngoài
return { type: 'about:blank', title: 'Internal Server Error', status: 500, instance, code: 'INTERNAL' }
}
const notFoundRoute: RequestHandler = (req, _res, next) => {
next(new NotFoundError('route', `${req.method} ${req.path}`))
}
const problemDetails: ErrorRequestHandler = (error, req, res, next) => {
if (res.headersSent) return next(error) // đang stream dở: để Express đóng connection
const problem = toProblem(error, req.originalUrl)
const logContext = { err: error, method: req.method, url: req.originalUrl, status: problem.status }
if (problem.status >= 500) logger.error(logContext, 'request failed')
else logger.info(logContext, 'request rejected')
res.status(problem.status).type('application/problem+json').json(problem)
}
app.use(notFoundRoute)
app.use(problemDetails) // luôn đăng ký cuối cùng
Response client nhận được (minh hoạ):
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{"type":"/problems/not-found","title":"NOT_FOUND","status":404,"detail":"order ord_404 not found","instance":"/orders/ord_404","code":"NOT_FOUND"}
Vì sao thiết kế như vậy:
res.headersSent: nếu lỗi xảy ra giữa lúc đang stream response, bạn không thể đổi status code được nữa. Delegate cho default handler của Express để nó đóng connection.typecó thể là URI tương đối (RFC 9457 cho phép); nếu có trang tài liệu lỗi thì dùng URL tuyệt đối trỏ tới đó.- Lỗi 4xx log ở
info, chỉ 5xx mới làerror. Nếu không, alert theo log level sẽ đầy 404 của bot quét. codelà extension member. Frontend switch theocodethay vì parsedetail, nên đổi câu chữ không làm vỡ client.
Express 4 thì sao? Rejection trong async handler không đi vào error middleware mà thành unhandledRejection. Hãy nâng lên Express 5 hoặc bọc mọi handler bằng wrapper gọi .catch(next).
Lỗi không ai bắt: unhandledRejection và uncaughtException
Từ Node 15, mặc định --unhandled-rejections=throw: một promise bị reject mà không có handler sẽ thành uncaught exception và process thoát với code khác 0. Đó là hành vi đúng. Tài liệu Node nói rõ resume bình thường sau uncaughtException là không an toàn, vì bạn không biết state nào đã hỏng giữa chừng.
Việc của bạn ở tầng process: ghi log fatal có đủ ngữ cảnh, ngừng nhận request mới, cho request đang chạy vài giây để xong, rồi thoát. Kubernetes (restartPolicy), systemd (Restart=on-failure) hay PM2 sẽ dựng lại process sạch.
// src/crash-handlers.ts
import type { Server } from 'node:http'
import { logger } from './logger.js'
export function installCrashHandlers(server: Server, shutdownTimeoutMs = 10_000): void {
let crashing = false
const crash = (origin: string) => (error: unknown) => {
logger.fatal({ err: error, origin }, 'fatal error, shutting down')
if (crashing) return
crashing = true
process.exitCode = 1
server.close(() => logger.flush(() => process.exit(1)))
// DB pool, Redis... giữ process sống: hết timeout thì thoát cứng
setTimeout(() => process.exit(1), shutdownTimeoutMs).unref()
}
process.on('uncaughtException', crash('uncaughtException'))
process.on('unhandledRejection', crash('unhandledRejection'))
}
Edge case cần biết:
- Khi đã đăng ký
uncaughtException, Node không tự thoát nữa. Quên gọiprocess.exitlà bạn vừa biến một crash thành một process zombie trả lỗi lung tung. - Nếu chỉ cần ghi log mà vẫn giữ hành vi crash mặc định, dùng
process.on('uncaughtExceptionMonitor', ...). Nó chạy trước khi Node thoát và không ngăn việc thoát. logger.flush()quan trọng khi bạn dùng pino transport hoặc destination async. Không flush thì dòng log quan trọng nhất (lý do chết) có thể mất.server.close()ngừng nhận connection mới và chờ request đang chạy xong. Từ Node 19 nó tự đóng luôn các keep-alive connection đang idle; muốn cắt cả request đang chạy thì cóserver.closeAllConnections().
Result<T, E> cho lỗi nghiệp vụ dự đoán được
Throw có hai điểm yếu với lỗi nghiệp vụ: signature hàm không nói nó có thể throw gì, và caller dễ quên xử lý. Với những kết quả mà caller bắt buộc phải phân nhánh (mã giảm giá hết hạn, hết hàng, vượt hạn mức), mình trả về discriminated union:
// src/result.ts
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value })
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error })
// src/coupons.ts
import { err, ok, type Result } from './result.js'
type Coupon = { code: string; percentOff: number; minOrderVnd: number; expiresAt: Date }
export type CouponError =
| { type: 'COUPON_NOT_FOUND' }
| { type: 'COUPON_EXPIRED'; expiredAt: Date }
| { type: 'MIN_ORDER_NOT_MET'; minOrderVnd: number }
export function applyCoupon(
coupon: Coupon | undefined,
orderTotalVnd: number,
now = new Date(),
): Result<{ discountVnd: number }, CouponError> {
if (!coupon) return err({ type: 'COUPON_NOT_FOUND' })
if (coupon.expiresAt <= now) return err({ type: 'COUPON_EXPIRED', expiredAt: coupon.expiresAt })
if (orderTotalVnd < coupon.minOrderVnd) return err({ type: 'MIN_ORDER_NOT_MET', minOrderVnd: coupon.minOrderVnd })
return ok({ discountVnd: Math.floor((orderTotalVnd * coupon.percentOff) / 100) })
}
export function couponMessage(e: CouponError): string {
switch (e.type) {
case 'COUPON_NOT_FOUND':
return 'Mã giảm giá không tồn tại'
case 'COUPON_EXPIRED':
return `Mã đã hết hạn lúc ${e.expiredAt.toISOString()}`
case 'MIN_ORDER_NOT_MET':
return `Đơn tối thiểu ${e.minOrderVnd.toLocaleString('vi-VN')}đ`
default: {
const unreachable: never = e // thêm case mới mà quên xử lý -> lỗi compile
return unreachable
}
}
}
Quy tắc chọn:
- Dùng
Resultkhi lỗi là một phần của domain, caller nào cũng phải xử lý, và bạn muốn compiler nhắc khi thêm loại lỗi mới. - Throw khi lỗi là bất ngờ (DB chết, bug) hoặc caller ngay phía trên không làm được gì ngoài đẩy lên tiếp. Bắt
Resultđi qua 6 tầng chỉ để tới error middleware là boilerplate vô ích. - Không bọc
Resultquanh mọi hàm async. MộtPromise<Result<...>>vẫn có thể reject vì lỗi hạ tầng, và điều đó ổn: tầng boundary đã lo phần đó.
Edge case: đừng để E là Error chung chung. Result<T, Error> không cho caller biết thêm gì so với throw. Sức mạnh nằm ở union có type cụ thể.
Retry: backoff + jitter, chỉ cho thao tác idempotent
Retry sai chỗ biến một sự cố nhỏ thành sự cố lớn: 500 pod cùng retry đúng sau 1s, 2s, 4s sẽ đập upstream đang yếu thành sập hẳn. Hai nguyên tắc: thêm jitter để các client tản ra, và chỉ retry thao tác idempotent (GET, PUT theo id, hoặc POST có Idempotency-Key mà server hỗ trợ).
// src/fetch-with-retry.ts
import { setTimeout as sleep } from 'node:timers/promises'
import { UpstreamError } from './errors.js'
type RetryOptions = {
service: string
idempotent: boolean
retries?: number
baseDelayMs?: number
maxDelayMs?: number
attemptTimeoutMs?: number
signal?: AbortSignal // signal của caller, ví dụ request đã bị client huỷ
}
const RETRIABLE_STATUS = new Set([408, 429, 502, 503, 504])
export async function fetchWithRetry(url: string, init: RequestInit, opts: RetryOptions): Promise<Response> {
const { retries = 3, baseDelayMs = 200, maxDelayMs = 5_000, attemptTimeoutMs = 3_000 } = opts
const maxAttempts = opts.idempotent ? retries + 1 : 1
let lastError: unknown
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const signals = [AbortSignal.timeout(attemptTimeoutMs), ...(opts.signal ? [opts.signal] : [])]
try {
const res = await fetch(url, { ...init, signal: AbortSignal.any(signals) })
if (res.ok || !RETRIABLE_STATUS.has(res.status)) return res
lastError = new Error(`${opts.service} responded ${res.status}`)
await res.body?.cancel() // trả connection về pool
const retryAfterSec = Number(res.headers.get('retry-after'))
if (attempt < maxAttempts - 1) {
await sleep(backoff(attempt, baseDelayMs, maxDelayMs, retryAfterSec), undefined, { signal: opts.signal })
}
} catch (error) {
if (opts.signal?.aborted) throw error // caller huỷ: dừng ngay, không retry
lastError = error // TimeoutError hoặc TypeError: fetch failed (xem error.cause.code)
if (attempt < maxAttempts - 1) {
await sleep(backoff(attempt, baseDelayMs, maxDelayMs), undefined, { signal: opts.signal })
}
}
}
throw new UpstreamError(opts.service, { cause: lastError })
}
// "Full jitter": random trong [0, min(max, base * 2^attempt)]
function backoff(attempt: number, baseMs: number, maxMs: number, retryAfterSec = Number.NaN): number {
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) return Math.min(retryAfterSec * 1_000, maxMs)
return Math.random() * Math.min(maxMs, baseMs * 2 ** attempt)
}
Những chỗ dễ sai:
AbortSignal.timeout()là timeout mỗi lần thử.fetchkhông có timeout mặc định ngắn, nên thiếu nó thì một upstream treo sẽ giữ request của bạn rất lâu.AbortSignal.any()(Node 20.3+) gộp nó với signal của caller.- Timeout khi POST không idempotent nghĩa là bạn không biết server đã xử lý hay chưa. Retry lúc đó có thể trừ tiền khách hai lần. Vì vậy hàm trên chỉ thử một lần khi
idempotent: false. - Retry ở nhiều tầng sẽ nhân lên: client retry 3 lần × gateway 3 lần × service 3 lần = 27 request cho một click. Chỉ retry ở một tầng, thường là tầng gần upstream nhất.
- Lỗi 4xx (trừ 408/429) không bao giờ retry. Gửi lại đúng payload sai thì vẫn sai.
Frontend: React error boundary, error.tsx và global-error.tsx
Error boundary bắt lỗi trong lúc render, trong lifecycle và constructor của cây con. Nó không bắt lỗi trong event handler, setTimeout, promise tự chạy, hay lỗi khi SSR. Với React thuần (ví dụ app Vite), mình dùng react-error-boundary thay vì tự viết class component:
// src/components/OrderPanel.tsx
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
import { OrderDetails } from './OrderDetails'
import { reportClientError } from '../lib/report-client-error'
function OrderFallback({ error, resetErrorBoundary }: FallbackProps) {
const message = error instanceof Error ? error.message : 'Unknown error'
return (
<div role="alert">
<p>Không tải được đơn hàng.</p>
{import.meta.env.DEV && <pre>{message}</pre>}
<button onClick={resetErrorBoundary}>Thử lại</button>
</div>
)
}
export function OrderPanel({ orderId }: { orderId: string }) {
return (
<ErrorBoundary
FallbackComponent={OrderFallback}
onError={(error, info) => reportClientError(error, { componentStack: info.componentStack })}
resetKeys={[orderId]} // đổi order thì tự reset boundary
>
<OrderDetails orderId={orderId} />
</ErrorBoundary>
)
}
Lỗi trong event handler thì bắt bằng try/catch rồi đặt vào state, hoặc chuyển cho boundary gần nhất qua showBoundary của hook useErrorBoundary. Từ React 19, createRoot(container, { onCaughtError, onUncaughtError }) là chỗ tập trung để gửi lỗi lên Sentry hay dịch vụ tương tự.
Trong Next.js App Router, error.tsx là một error boundary cho route segment. Phải là Client Component. Từ Next.js 16.3, prop retry là API stable để re-fetch và render lại segment; 16.2 có nó dưới tên unstable_retry, bản cũ hơn chỉ có reset. error có kiểu unknown và phải narrow trước khi đọc.
// app/orders/[id]/error.tsx
'use client'
import { useEffect } from 'react'
export default function OrderError({ error, retry }: { error: unknown; retry: () => void }) {
const digest =
typeof error === 'object' && error !== null && 'digest' in error ? String(error.digest) : undefined
useEffect(() => {
// Production: lỗi từ Server Component chỉ có message chung + digest
console.error('order page failed', { digest })
}, [digest])
return (
<div role="alert">
<h2>Không tải được đơn hàng</h2>
{digest && <p>Mã lỗi: {digest}</p>}
<button onClick={() => retry()}>Thử lại</button>
</div>
)
}
// app/global-error.tsx
'use client'
export default function GlobalError({ retry }: { error: unknown; retry: () => void }) {
return (
// global-error thay thế root layout nên phải tự có <html> và <body>
<html lang="vi">
<body>
<h2>Hệ thống đang gặp sự cố</h2>
<button onClick={() => retry()}>Tải lại</button>
</body>
</html>
)
}
Ba điều hay bị bỏ qua:
error.tsxkhông bọclayout.tsxcùng cấp. Lỗi trong root layout chỉglobal-error.tsxbắt được, và file này không có global CSS hay font của bạn.- Ở production, message của lỗi Server Component bị thay bằng message chung để không lộ thông tin.
digestlà cầu nối: hiển thị nó cho user, rồi tìm đúng digest đó trong log server. Phía server, exportonRequestErrortronginstrumentation.ts(Next.js 15+) để gửi lỗi đi kèmroutePathvàrouteType. - Lỗi dự đoán được trong Server Action (validation, hết hàng) nên được trả về như một giá trị (đúng tinh thần
Resultở trên) để form hiển thị, thay vì throw rồi rơi vàoerror.tsx.
Log lỗi một lần, giữ nguyên cause chain
Nguyên tắc: log ở boundary (error middleware, crash handler, job runner), không log ở mỗi tầng rồi throw tiếp. Log-and-rethrow ở 4 tầng cho bạn 4 dòng log cho cùng một lỗi, và không dòng nào đủ ngữ cảnh.
Với pino, truyền lỗi dưới key err. Serializer err mặc định gộp message và stack của cause vào lỗi ngoài cùng, nhưng bỏ các property khác của cause (như code: 'ECONNRESET'). Muốn giữ cause thành object lồng nhau, đổi sang errWithCause:
// src/logger.ts
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
serializers: { err: pino.stdSerializers.errWithCause },
})
Output (minh hoạ, đã rút gọn stack):
{"level":50,"msg":"request failed","status":503,"err":{"type":"UpstreamError","message":"payment-gateway is unavailable","code":"UPSTREAM_UNAVAILABLE","service":"payment-gateway","stack":"UpstreamError: payment-gateway is unavailable\n at chargeOrder (src/payments.ts:18:11)","cause":{"type":"TypeError","message":"fetch failed","stack":"TypeError: fetch failed\n at ...","cause":{"type":"Error","message":"read ECONNRESET","code":"ECONNRESET"}}}}
Nhìn dòng này là biết ngay: lỗi không nằm ở logic thanh toán mà ở kết nối TCP bị reset. Đó chính là thông tin bị mất trong câu chuyện 3 giờ sáng ở đầu bài. Redact, correlation ID và cách ship log đi đâu có ở bài logging và observability.
Những lỗi thường gặp
Nuốt lỗi bằng catch rỗng hoặc return null:
// Sai: lỗi DB biến thành "không tìm thấy", user nhận 404 cho một sự cố 500
async function getInvoice(id: string) {
try {
return await db.invoice.findUniqueOrThrow({ where: { id } })
} catch {
return null
}
}
// Đúng: chỉ chuyển đổi đúng loại lỗi mình hiểu, còn lại để nó bay lên
async function getInvoice(id: string) {
const invoice = await db.invoice.findUnique({ where: { id } })
if (!invoice) throw new NotFoundError('invoice', id)
return invoice
}
Wrap làm mất stack và cause:
// Sai
catch (e) { throw new Error((e as Error).message) }
// Đúng
catch (error) { throw new UpstreamError('inventory-service', { cause: error }) }
Trả nguyên lỗi cho client:
// Sai: lộ câu SQL, tên bảng, đôi khi cả connection string
res.status(500).json({ error: String(err), stack: (err as Error).stack })
// Đúng: client nhận problem+json chung chung, chi tiết nằm trong log
forEach với async callback: orders.forEach(async (o) => await sync(o)) không chờ gì cả, lỗi thành unhandled rejection. Dùng for...of với await, hoặc Promise.allSettled(orders.map(sync)). Rule no-misused-promises bắt được lỗi này.
Retry POST không có idempotency key: timeout không có nghĩa là thất bại. Hoặc gửi Idempotency-Key mà server hỗ trợ, hoặc đừng retry.
Dùng process.on('uncaughtException') để "giữ server sống": process tiếp tục chạy với state không xác định (transaction dở, lock chưa nhả). Log rồi thoát.
Checklist
- Có một
AppErrorbase vớicode,httpStatus,expose, và mọi chỗ wrap lỗi đều truyềncause. - tsconfig bật
strict(kéo theouseUnknownInCatchVariables), lỗi trongcatchđược narrow trước khi dùng. - ESLint bật
no-floating-promises,no-misused-promises,only-throw-error,return-awaitvới type information. - Đã lên Express 5 (hoặc mọi async handler được bọc
.catch(next)), và chỉ có một error middleware trảapplication/problem+json. - Response 5xx không chứa message, stack hay SQL; 4xx có
codeổn định cho frontend. - Crash handler log
fatal, flush log, đóng server có timeout rồiexit(1). - Lỗi nghiệp vụ mà caller bắt buộc xử lý được mô hình hoá bằng
Resultvới union cótype. - Mọi call ra ngoài có
AbortSignal.timeout; retry chỉ cho thao tác idempotent, có jitter, ở đúng một tầng. - Next.js có
error.tsxcho các segment quan trọng,global-error.tsxcó<html>/<body>, vàonRequestErrorgửi lỗi server đi. - Logger dùng
errWithCausevà lỗi chỉ được log một lần ở boundary.
Đọc thêm
- RFC 9457: Problem Details for HTTP APIs
- Express: Error handling
- Node.js: process events (
uncaughtException,unhandledRejection) - typescript-eslint: no-floating-promises
- Next.js: error.js
- pino-std-serializers
- Bài liên quan: Logging và observability cho Node.js, Thiết kế REST API dễ dùng, khó dùng sai, Thiết lập code quality cho dự án TypeScript