NestJS 後端 i18n 的優雅解法:用 PostgreSQL JSONB + 裝飾器打造型別安全的多語系架構
後端 i18n 常見的爛做法:為什麼我不想再看到 name_zh / name_en
每次接手有多語系需求的專案,最常看到的做法大概是這樣:
CREATE TABLE products (
id UUID PRIMARY KEY,
name_zh_tw TEXT,
name_zh_cn TEXT,
name_en TEXT,
description_zh_tw TEXT,
description_zh_cn TEXT,
description_en TEXT
);
欄位數量直接乘上語言數量。今天要多支援一個語言,就是一次 migration 加一批欄位,然後所有 DTO、所有 Service、所有 query 都要跟著改。
更麻煩的是,這種做法在 TS 這層幾乎沒辦法做到真正的型別約束——你很難在 type 層面表達「這三個欄位是同個概念的不同語言版本」,也很難統一處理回傳邏輯。
我現在想要的是:
- 資料庫只存一個欄位,裡面放所有語言
- TS 有嚴格的型別,不能亂塞值
- Controller 不用每次手動去撈對應語言,應該自動處理
用 PostgreSQL JSONB 儲存多語系欄位:結構設計
JSONB 是這個架構的基礎。欄位定義長這樣:
CREATE TABLE products (
id UUID PRIMARY KEY,
name JSONB NOT NULL,
description JSONB
);
實際存進去的資料:
{
"zh_tw": "產品名稱",
"zh_cn": "产品名称",
"en": "Product Name"
}
用 JSONB 而不是 JSON 的原因是 JSONB 支援索引(GIN index),如果之後有搜尋需求可以直接加上去,不需要改結構。
TypeORM 這層的 entity 定義:
@Entity()
export class Product {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'jsonb' })
name: MultiLangString;
@Column({ type: 'jsonb', nullable: true })
description: MultiLangString | null;
}
給 JSONB 加上 TypeScript 嚴格型別:從資料庫到應用層
只靠 jsonb 欄位沒有型別保護,塞什麼進去資料庫都不會報錯,所以在 TS 層面定義 MultiLangString:
export interface MultiLangString {
zh_tw?: string;
zh_cn?: string;
en?: string;
}
對應的 Language enum:
export enum Language {
ZH_TW = 'zh_tw',
ZH_CN = 'zh_cn',
EN = 'en',
}
這樣 entity 上的 name: MultiLangString 就有完整的型別推導,不能塞 { jp: '...' } 這種不存在的語言 key。
用類別型別判斷多語系欄位
問題是:Interceptor 在執行期拿到的是一個普通物件,它不知道哪些欄位是多語系的、哪些只是剛好長得像 { en: 'something' } 的普通物件。
這裡用 isMultiLangString 做 runtime 型別判斷:
function isMultiLangString(value: unknown): value is MultiLangString {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return false;
const langKeys = Object.values(Language) as string[];
return entries.every(
([k, v]) => langKeys.includes(k) && (v === undefined || typeof v === 'string')
);
}
邏輯是:所有的 key 都必須是合法的語言代碼,且 value 都是 string 或 undefined。這樣就能精準識別多語系物件,不會誤判其他結構。
接著 translateData 遞迴遍歷整個回傳資料,遇到多語系物件就取出對應語言,遇到 array 或 plain object 就繼續往下走:
export function translateData(data: unknown, lang: Language): unknown {
if (isMultiLangString(data)) {
return data[lang] ?? Object.values(data).find(Boolean) ?? '';
}
if (Array.isArray(data)) {
return data.map(item => translateData(item, lang));
}
if (
data !== null &&
typeof data === 'object' &&
Object.getPrototypeOf(data) === Object.prototype
) {
return Object.fromEntries(
Object.entries(data as Record<string, unknown>).map(([k, v]) => [k, translateData(v, lang)])
);
}
return data;
}
值得注意的是 Object.getPrototypeOf(data) === Object.prototype 這個判斷——這樣可以確保只遞迴 plain object,不會誤觸 Date、Buffer 這類特殊物件。
在 Controller 層自動解析語言:@Translate 裝飾器 + Interceptor
有了 translateData,下一步是讓 Controller 不用手動呼叫它。
先定義裝飾器:
export const TRANSLATE_KEY = 'translate';
export const Translate = () => SetMetadata(TRANSLATE_KEY, true);
然後在 TransformInterceptor 裡讀取這個 metadata,決定要不要做翻譯:
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
constructor(private readonly reflector: Reflector) {}
intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<ApiResponse<T>> {
const shouldTranslate = this.reflector.getAllAndOverride<boolean>(
TRANSLATE_KEY,
[context.getHandler(), context.getClass()]
);
const lang = shouldTranslate
? parseLang(
context
.switchToHttp()
.getRequest<{ headers: Record<string, string> }>()
.headers['accept-language']
)
: undefined;
return next.handle().pipe(
map((data) => ({
code: 200,
message: 'ok',
data: lang ? (translateData(data, lang) as T) : (data ?? null),
}))
);
}
}
parseLang 負責解析 Accept-Language header,把 zh-TW,zh;q=0.9 這種格式轉成 Language.ZH_TW。
使用時,Controller 只需要加一個 @Translate():
@Controller('products')
@UseInterceptors(TransformInterceptor)
export class ProductController {
@Get(':id')
@Translate()
findOne(@Param('id') id: string) {
return this.productService.findOne(id);
}
}
沒有加 @Translate() 的 endpoint,回傳的就是原始的 MultiLangString 物件,方便後台管理用。
DTO 的嚴格限制:驗證輸入的多語系欄位
寫入的時候,需要確保前端傳進來的多語系欄位格式正確。這裡用 class-validator + class-transformer 做:
export class MultiLangDto {
@ApiProperty({ description: '繁體中文語系' })
@IsString()
@IsNotEmpty()
zh_tw!: string;
@ApiProperty({ description: '簡體中文語系' })
@IsString()
@IsNotEmpty()
zh_cn!: string;
@ApiProperty({ description: '英文語系' })
@IsString()
@IsNotEmpty()
en!: string;
}
export class UpdateMultiLangDto extends PartialType(MultiLangDto) {}
MultiLangDto 用在建立,三個語言都必填。UpdateMultiLangDto 繼承 PartialType,更新時只需要傳要改的語言。
在 Product DTO 裡使用:
export class CreateProductDto {
@ApiProperty({ type: MultiLangDto })
@ValidateNested()
@Type(() => MultiLangDto)
name!: MultiLangDto;
@ApiProperty({ type: MultiLangDto, required: false })
@IsOptional()
@ValidateNested()
@Type(() => MultiLangDto)
description?: MultiLangDto;
}
@ValidateNested() + @Type() 這組搭配是關鍵,少了 @Type() 的話 class-transformer 不會做巢狀轉型,驗證就會過不了。
這套做法的限制與我還在思考的取捨
isMultiLangString 的誤判風險:如果專案裡剛好有其他物件的 key 全部是語言代碼,就會被當成多語系物件處理。目前的判斷邏輯是 structural(看結構),不是 nominal(看型別名稱),這是一個 tradeoff。比較嚴謹的做法是在物件上加一個標記 symbol,但這樣 entity 就不能是 plain object,複雜度會上升。
translateData 只處理 plain object:如果 Service 回傳的是 class instance(比如 TypeORM entity),Object.getPrototypeOf(data) === Object.prototype 這個判斷就會 false,不會被遞迴處理。實務上通常在 Service 層做一次 plainToInstance 或 spread 轉換,但這個要記得。
語言 fallback 策略:目前的邏輯是 data[lang] ?? Object.values(data).find(Boolean) ?? '',找不到對應語言就 fallback 到第一個有值的語言。如果有更細緻的 fallback 需求(例如 zh_cn 找不到要先試 zh_tw 再試 en),就需要另外定義 fallback chain。
多租戶支援的語言集合:如果不同客戶支援的語言不一樣,Language enum 就不夠用,需要改成動態設定。這個場景我目前還沒遇到,先留著。