번역 키를 컴파일 타임에 잡기 #3 — JSON을 타입으로, 도구 없이 declare module로 키 잠그기
번역 키를 타입으로 잠그려고 코드 생성기부터 떠올렸는데, 막상 해보니 내가 직접 짠 건 열 줄 남짓이었다. 중첩 키 평탄화 같은 어려운 부분은 i18next 타입이 이미 다 해두고 있었다.
번역 키를 타입으로 잠그려고, 나는 먼저 “코드를 훑어
.d.ts를 뽑아주는 생성기”부터 떠올렸다. 그런데 막상 해보니 내가 직접 짠 건 열 줄 남짓이었다. 중첩 키 평탄화처럼 어려운 부분은, 이미 i18next 타입이 다 해두고 있었기 때문이다.
#2에서 “번역 함수의
key를 유효 키 집합으로 좁히면 오타·미정의·네임스페이스 오용이 컴파일 에러가 된다”를 봤다. 이번 글은 그 타입을 실제로 만드는 방법이다.
1. 큰 그림 — 어디까지 내가 짜고, 어디부터 라이브러리가 하나
목표는 번역 함수 시그니처를 이렇게 바꾸는 것이다.
function t(key: string): string // Before — 무엇이든 통과
function t(key: '유효키1' | '유효키2' | …): string // After — 유효 키만
이 union을 만드는 일을 역할로 나누면:
| 단계 | 누가 | TypeScript 기능 |
|---|---|---|
| JSON을 타입으로 | 나 | resolveJsonModule + typeof import |
| 런타임 병합 반영 | 나 | Omit + intersection(&) |
| 라이브러리에 꽂기 | 나 | declare module 선언 병합 |
중첩 키 평탄화(a.b.c) | i18next | keyof + 인덱스 접근 + 재귀 |
네임스페이스 조립(ns:key) | i18next | 템플릿 리터럴 타입 |
| 복수형·context·keyPrefix | i18next | 조건부 타입 + infer |
2. Step 1 — JSON을 타입으로 (resolveJsonModule + typeof)
번역 키는 대개 JSON 파일에 있다.
// en.json
{ "user": { "profile": { "title": "My Profile" } }, "close": "Close" }
tsconfig에 resolveJsonModule: true를 켜면 이 JSON을 import할 수 있고, TypeScript가 그 구조를 타입으로 추론한다.
import type en from './en.json';
type Resources = typeof en;
// → { user: { profile: { title: string } }; close: string }
여기 중요한 성질이 하나 있다. JSON import는 키(프로퍼티 이름)는 정확히 주지만, 값 타입은 넓게(string) 준다. 우리가 키 정합성에 필요한 건 키뿐이라 값이 넓은 건 상관없다.
왜 “값이 넓다”가 중요한가 — 로케일 완전성은 여기서 안 된다. “영어 파일의 키가 한국어 파일에도 다 있는가”(완전성)를 타입으로 검사하려는 시도가 있다. 하지만 JSON import가 값을 넓게 추론하는 탓에 구조적 타이핑상 키 누락을 못 잡는다(실측:
satisfies로 시도했으나 0건 검출 + 컴파일 시간 증가).as const로 값을 리터럴화하는 codegen을 하면 값은 얻지만 완전성이 자동으로 따라오진 않는다 — 완전성은 애초에 “여러 파일의 키 집합 대조”라는 별개 문제이기 때문이다. 그래서 완전성은 타입이 아니라 별도 스크립트로 두는 게 맞다. (이 글의 타입은 “코드가 부르는 키가 존재하는가”만 담당한다.)
3. Step 2 — 중첩 평탄화는 내가 안 짠다 (라이브러리가 함)
우리 키는 user.profile.title(중첩)이나 settings:theme(ns:key) 형태다. 이걸 순수 타입으로 union화하려면 재귀 타입이 필요한데 — 다행히 i18next가 이미 다 짜뒀다. 다만 그 안에서 무슨 일이 일어나는지는 알아둘 가치가 있다. 조각으로 분해해 보자.
조각 A — keyof : 키만 뽑기
type T = { user: { name: string }; close: string };
type K = keyof T; // → 'user' | 'close'
조각 B — 인덱스 접근 T[K] : 값 꺼내기
type V = T['user']; // → { name: string }
type All = T[keyof T]; // → { name: string } | string (인덱스에 union → 값도 union)
T[keyof T]가 “모든 값의 union” 이 되는 트릭을 기억하자.
조각 C — 매핑된 타입 : 값을 “변환”하며 union 뽑기
여기서 흔히 헷갈리는 지점 하나. 이런 코드를 보면:
type Values<T> = { [K in keyof T]: T[K] }[keyof T];
{ [K in keyof T]: T[K] }는 값을 그대로(T[K]) 돌려놓은 항등 매핑이라 T와 완전히 같다. 그러니 이건 그냥 T[keyof T]와 다를 게 없다 — 매핑을 쓸 이유가 없다. tsc로 확인해도 동일하다:
type T = { a: number; b: string };
type V1 = T[keyof T]; // number | string
type V2 = { [K in keyof T]: T[K] }[keyof T]; // number | string ← V1 과 똑같음
매핑이 의미를 갖는 건 값 자리에서 “키 K를 참조하거나 값을 변환”할 때뿐이다:
type V3 = { [K in keyof T]: K }[keyof T]; // 'a' | 'b' ← 값자리에 키
type V4 = { [K in keyof T]: `${K & string}?` }[keyof T]; // 'a?' | 'b?' ← 키를 변환
T[keyof T]는 값만 주고 “그 값이 어느 키에서 왔는지”를 잃는다. 키를 경로에 붙이려면(user.name) 반드시 매핑이 필요하다.
조각 D — 조건부 타입 + infer : “더 파고들까, 여기서 끝낼까”
type IsObject<V> = V extends object ? 'GO_DEEPER' : 'LEAF';
A extends B ? X : Y — B에 맞으면 X, 아니면 Y. infer는 패턴 안에서 부분을 추출한다. i18next가 keyPrefix를 벗겨낼 때 이걸 쓴다(실측 코드):
Keys extends `${KPrefix}${Sep}${infer Key}` ? Key : never
// 'settings.theme' 에서 'theme' 만 infer 로 뽑음
조각 E — 템플릿 리터럴 : 경로 잇기
type Path = `${'user'}.${'name'}`; // → 'user.name'
조립 — FlattenKeys (재귀) + 실전 함정
다섯 조각을 합치면 중첩 객체를 점 표기 키 union으로 펴는 타입이 된다.
// ⚠️ 이 "순진한" 버전은 실제로는 컴파일 에러(TS2589)가 난다
type FlattenKeys<T> = {
[K in keyof T]: T[K] extends object
? `${K & string}.${FlattenKeys<T[K]> & string}` // 재귀 + 템플릿
: `${K & string}`;
}[keyof T];
여기가 블로그 예제로 자주 보이지만 함정이다. 위 버전을 tsc에 돌리면 TS2589: Type instantiation is excessively deep and possibly infinite가 난다. TypeScript가 재귀의 종료를 정적으로 확신하지 못해서다. 바깥에 T extends object ? 가드를 하나 씌워야 컴파일러가 종료를 인식한다:
type FlattenKeys<T> = T extends object // ← 이 가드가 있어야 TS2589 안 남
? {
[K in keyof T]: T[K] extends object
? `${K & string}.${FlattenKeys<T[K]> & string}`
: `${K & string}`;
}[keyof T]
: never;
type R = FlattenKeys<{ user: { name: string; addr: { city: string } }; close: string }>;
// → 'user.name' | 'user.addr.city' | 'close' (tsc 확인)
손으로 한 스텝씩 따라가면:
FlattenKeys<{ user: { name: string; addr: {city:string} }; close: string }>
K='user': 값이 객체 → `user.${ FlattenKeys<{name; addr}> }`
├ K='name': string(끝) → 'name' ⇒ 'user.name'
└ K='addr': 객체 → `addr.${ FlattenKeys<{city}> }`
└ K='city': string(끝) → 'city' ⇒ 'user.addr.city'
K='close': string(끝) → 'close'
[keyof T] 로 값 union 추출 ⇒ 'user.name' | 'user.addr.city' | 'close'
재귀는 leaf(string)에서 멈추므로 무한 루프가 안 된다.
i18next 실제 코드와의 대응
방금 만든 조각들이 i18next 타입 정의(공개 npm 패키지)에 그대로 있다.
// [E · 경로 잇기] 중첩 키를 . 으로 조립
type JoinKeys<K1, K2> = `${K1 & string}${Sep}${K2 & string}`;
// [E · ns 붙이기] 네임스페이스를 : 로 조립
type AppendNamespace<Ns, Keys> = `${Ns & string}:${Keys & string}`;
// [D · 조건부 + infer] keyPrefix 를 벗겨 나머지 키만 추출
type ParseByPrefix<Keys, KPrefix> =
Keys extends `${KPrefix}${Sep}${infer Key}` ? Key : never;
// [A+B+C+D+E · 재귀] 위를 조합해 전체 키 union 생성 → ParseKeys<...>
우리는 이걸 안 짠다. Step 1의 Resources만 넘기면 i18next의 ParseKeys가 이 트레이스를 자동으로 돌려 전체 키 union을 만든다.
4. Step 3 — CustomTypeOptions 선언 병합으로 꽂기
그럼 Resources를 어디에 넘기나? i18next는 CustomTypeOptions라는 인터페이스를 비어 있는 채로 내보낸다(실측):
// i18next 내부
export interface CustomTypeOptions {} // ← 사용자가 채우라고 비워둔 구멍
여기에 선언 병합(declaration merging) — 같은 인터페이스를 다시 선언하면 TypeScript가 합쳐주는 기본 동작 — 을 쓴다. 단, 다른 모듈의 인터페이스라 모듈 augmentation 형태로 감싸야 한다:
import type en from './en.json';
declare module 'i18next' {
interface CustomTypeOptions {
resources: typeof en;
}
}
이 순간 i18next의 ParseKeys가 우리 en 구조를 재료로 전체 키 union을 만들고, t의 파라미터가 그걸로 좁혀진다.
⚠️ 주의 두 가지. (1) 전역에 그냥
interface CustomTypeOptions {}를 쓰면 i18next의 것과 안 합쳐진다 — 반드시declare module 'i18next'로 감싸야 한다. (2) 이 선언은 ambient(전역) 라 import 없이 프로그램 전체에 적용된다. 편리하지만, 이게 어디까지 전파되는지·barrel로 export하면 왜 다른 앱까지 오염되는지는 모듈 augmentation의 평가 방식을 알아야 안전하게 다룰 수 있다 — 이 주제는 이 시리즈 뒤 글에서 따로 판다.
5. Step 4·5 — 네임스페이스·구분자, 그리고 런타임 병합
키가 여러 파일(네임스페이스)로 나뉘어 있으면 settings:theme처럼 ns:key 형태를 쓴다. 기본값과 다르면 구분자와 기본 네임스페이스도 함께 선언하면 된다(i18next 기본은 keySeparator: '.', nsSeparator: ':'):
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: Resources;
keySeparator: '.';
nsSeparator: ':';
}
}
한 가지 더. 런타임에 리소스를 합쳐서 쓰는 경우(예: 공통 번들을 특정 네임스페이스에 병합)엔 타입이 그걸 자동으로 못 본다. Omit + intersection으로 손수 반영한다:
type Resources = Omit<typeof en, 'settings'> & {
settings: typeof en.settings & typeof sharedEn; // 런타임 병합 반영
};
6. 최종 형태 (이게 전부)
import type en from './en.json';
import type sharedEn from './shared.json';
type Resources = Omit<typeof en, 'settings'> & {
settings: typeof en.settings & typeof sharedEn;
};
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: Resources;
keySeparator: '.';
nsSeparator: ':';
}
}
약 10줄. 나머지(수백 개 키의 union, 중첩 평탄화, ns 조립)는 i18next 타입이 자동으로 처리한다.
7. “도구 없이”의 실체
이런 걸 코드 생성기(코드를 훑어 .d.ts를 뽑아주는 도구)로 하기도 한다. 하지만 그런 도구의 핵심은 코드를 AST로 파싱해 키를 추출하는 것이고, 그 추출기는 보통 수천 줄 규모다.
타입 경로는 그 추출을 통째로 우회한다. JSON을 typeof로 타입화하고, 코드의 t() 인자를 그 타입에 대조하는 일은 tsc가 이미 무료로 해준다. 게다가 JSON이 곧 타입이라 키가 바뀌면 자동 최신화된다(생성물 재생성 없음). 우리가 쓴 건 typeof import + Omit/& + declare module — 전부 순수 TypeScript 기본기다. (빌드 시간 증가는 실측상 한 자릿수 %대로, 타입은 런타임에 소거되니 앱 성능엔 영향이 없다 — 1편의 결론 그대로.)
정리 — 그리고 다음 글
- Step 1:
resolveJsonModule+typeof import로 JSON을 타입화. 키는 정확, 값은 넓게(완전성은 별도 스크립트). - Step 2: 중첩 평탄화·ns 조립은 i18next가
keyof·인덱스 접근·조건부 타입·템플릿 리터럴·재귀로 처리한다. 우리는 재료만 넘긴다. (재귀 타입엔T extends object ?가드가 필요하다는 실전 함정도 봤다.) - Step 3~5:
declare module로CustomTypeOptions에 선언 병합. ns/구분자·런타임 병합은 옵션.
여기까지 오면서 keyof·조건부 타입·템플릿 리터럴을 썼지만, 왜 그게 동작하는지는 파고들지 않았다. 다음 글(#4)에서는 그 밑바닥을 판다 — “타입은 값의 집합이다” 하나로 union·extends·조건부·분배·never를 꿰고, i18next의 실제 타입 한 줄을 직접 읽어낸다. 그 사고법이 잡히면 남는 두 조각 — 동적 키(t(변수))와 모듈 augmentation(빌드 시 평가·전파·barrel 오염) — 은 그 뒤 글에서 판다.