번역 키를 컴파일 타임에 잡기 #4 — 타입을 집합으로 보기, i18next 타입을 읽는 법
#3에서 keyof·조건부 타입·템플릿 리터럴을 "썼지만" 왜 동작하는지는 파고들지 않았다. '타입은 값의 집합이다' 한 문장으로 그 밑바닥을 꿰고, i18next의 실제 타입 한 줄을 직접 읽어냈다.
#3에서
declare module하나로 번역 키를 잠갔다. 그때keyof·조건부 타입·템플릿 리터럴 같은 도구를 “썼지만”, 왜 그게 동작하는지는 파고들지 않았다. 이번 글은 그 밑바닥이다. “타입은 값의 집합이다” 라는 문장 하나로 union·extends·조건부·분배·never·튜플을 꿰고, 마지막엔 i18next의 실제 타입 한 줄을 직접 읽어냈다. (동적 키·모듈 augmentation은 이 사고법 위에서 다음 글에.)
0. 마스터키 — 타입은 값의 집합이다
시작 전에 문장 하나만 머리에 새기자. 이 글의 거의 모든 게 여기서 풀린다.
타입 = 그 타입에 속할 수 있는 값들의 집합.
string= 모든 문자열의 집합 (무한히 큼)'close'= 딱 하나"close"만 든 집합 (원소 1개짜리) — 이런 걸 문자열 리터럴 타입이라 한다number= 모든 숫자의 집합
이 관점으로 보면, 뒤에 나올 union·extends·조건부 타입이 전부 집합 연산으로 읽힌다. 타입스크립트의 타입 시스템은 사실상 “집합을 계산하는 작은 언어”라고 본다.
1. union = 합집합
union 타입은 |로 여러 타입을 잇는다. 그리고 그 |는 문자 그대로 합집합(∪) 이다.
type A = 'a' | 'b' | 'c'; // {a} ∪ {b} ∪ {c} = {a, b, c}
이름이 union(합집합)인 게 우연이 아니다. i18next가 번역 키를 검사하는 원리가 바로 이거다 — 존재하는 모든 키를 리터럴 타입으로 만들어 하나의 거대한 합집합으로 엮는다.
type Key = 'user.title' | 'user.name' | 'close' | …; // 유효한 키 전부의 합집합
그리고 번역 함수의 시그니처를 t(key: string)이 아니라 t(key: Key)로 좁히면, 이 집합에 없는 키는 컴파일러가 거부한다. 미정의 키·오타가 컴파일 에러가 되는 것이 이 글 전체의 목적지다.
2. 대입 = 부분집합(⊆) — string이 거부되는 이유
그럼 “이 집합에 없으면 거부”는 정확히 어떻게 판정될까? 타입스크립트의 대입 규칙은 집합으로 보면 딱 한 줄이다.
대입 가능 ⟺ 넣으려는 것(source)의 집합이 받는 자리(target)의 집합에 통째로 들어간다 (source ⊆ target).
즉 좁은 걸 넓은 데 넣는 건 OK, 넓은 걸 좁은 데 넣는 건 안 된다.
let key: 'a' | 'b' | 'c';
key = 'a'; // ✅ {a} ⊆ {a,b,c} (좁은 → 넓은)
let s: string;
key = s; // ❌ {모든 문자열} ⊄ {a,b,c} (넓은 → 좁은)
t('user.title')가 통과하는 이유(리터럴 하나는 키 집합의 원소), 그리고 string을 넣으면 거부되는 이유(string은 키 집합보다 넓은 상위집합이라 통째로 못 들어감)가 전부 이 한 규칙에서 나온다.
여기서 나도 처음에 헷갈렸던 것 하나. string이 거부되는 건 “리터럴 값이 안 맞아서”가 아니다. string은 애초에 특정 리터럴 값을 갖지 않는다. 런타임에 "zzz"가 될 수도 있어서 “유효 키 중 하나임을 보장할 수 없기 때문에” 통째로 거부되는 것이다. (반면 리터럴 'user.titel'은 값이 하나로 정해져 있으니, “그게 키 집합의 원소냐?”를 직접 따져 없으면 거부한다.)
3. extends = ⊆
이제 핵심 키워드. extends는 타입스크립트에서 “부분집합이다”를 나타내는 기호다.
A extends B= “A는 B의 부분집합” (A ⊆ B)
이게 세 자리에서 똑같은 뜻으로 쓰인다.
| 자리 | 예 | 뜻 (전부 ⊆) |
|---|---|---|
| 상속 | interface Dog extends Animal | Dog는 Animal의 부분집합 (선언) |
| 제네릭 제약 | <T extends Animal> | T는 Animal의 부분집합이어야 (요구) |
| 조건부 타입 | Dog extends Animal ? X : Y | Dog가 Animal의 부분집합인가? (질문) |
상속이 부분집합이라는 게 특히 반직관적이라 짚고 가자. 나도 한동안 반대로 알고 있었다.
interface Animal { name: string }
interface Dog extends Animal { bark(): void } // name 상속 + bark 추가
Animal집합 = “name만 있으면 되는 것” → 넓음Dog집합 = “name도 있고 게다가bark도 있는 것” → 좁음
멤버(조건)를 추가하면 만족해야 할 게 늘어나 해당되는 값은 줄어든다. 그래서 Dog ⊆ Animal. “extends”라는 단어는 인터페이스(멤버 목록)를 확장한다는 뜻이지, 값 집합을 키운다는 뜻이 아니다 — 오히려 값 집합은 좁아진다. 그리고 이건 2절의 대입 규칙 그대로다: Dog(좁음)는 Animal(넓음) 자리에 들어가지만, 반대는 안 된다(Animal은 bark를 보장 못 하니까). string → 키 union이 막히는 것과 같은 규칙.
4. 제네릭 · 타입 파라미터 · naked
조건부 타입으로 가기 전에 어휘 정리. 셋은 계단 관계다.
- 제네릭(generic) —
<T>로 타입을 매개변수화하는 문법 전체:type Foo<T> = … - 타입 파라미터(type parameter) — 그 안의
T자체 (자리표시자) - naked(벌거벗은) 타입 파라미터 — 그
T가 조건부의 검사 자리에 아무것도 안 씌운 맨몸으로 등장한 상태
“naked”는 새 종류가 아니라, T를 어떻게 쓰느냐를 가리키는 형용사다. 이게 왜 중요한지는 6절(분배)에서 결정적으로 드러난다.
type F1<T> = T extends string ? 1 : 2; // T 가 naked
type F2<T> = [T] extends [string] ? 1 : 2; // T 를 튜플로 감쌈 → naked 아님
5. 조건부 타입 — ⊆를 묻는 질문
type IsString<T> = T extends string ? 'yes' : 'no';
A extends B ? X : Y는 JS의 삼항 연산자와 똑같은 모양이고, 뜻은 “A가 B의 부분집합이면 X, 아니면 Y” 다. extends가 ⊆(3절)이니, 조건부 타입은 결국 “⊆인가?”를 묻고 분기하는 것이다.
IsString<'a'> // 'a' ⊆ string ? → 'yes'
IsString<number> // number ⊆ string ? → 'no'
6. 분배 — union에 .map()
여기가 타입 레벨 프로그래밍의 심장이다. naked 타입 파라미터(4절)에 union이 들어오면, 조건부 타입은 union을 멤버마다 따로 돌린 뒤 결과를 다시 union으로 합친다. 이걸 분배(distribution) 라 한다.
가장 좋은 비유는 배열의 .map()이다. 분배 = union에 대한 .map().
[a, b, c].map(f) → [f(a), f(b), f(c)] // 배열
F<a | b | c> → F<a> | F<b> | F<c> // union 분배 (naked T 조건부)
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;
// 쪼갬: string, number
// 적용: string[], number[]
// 합침: string[] | number[]
분배가 유용한 이유 — 필터링
never(다음 절)는 union에서 사라지는 성질이 있어서(X | never = X), 분배 중 특정 멤버에 never를 반환하면 그 멤버를 걸러낼 수 있다. 내장 Exclude가 정확히 이 원리다.
type Exclude<T, U> = T extends U ? never : T;
type R = Exclude<'a' | 'b' | 'c', 'b'>; // 'a' | 'c'
분배 = map, never 반환 = 그 원소 버리기(filter). 이 둘로 union을 자유롭게 변형한다.
7. never와 튜플 — 분배를 끄는 법
never는 집합으로 보면 공집합(원소 0개)이다. 이 성질이 두 곳에서 중요하다.
첫째, 공집합을 분배하면 아무것도 안 나온다. 분배는 멤버마다 도는 .map()인데, 멤버가 0개면 돌릴 게 없으니 결과도 never다. 그래서 naked 파라미터로 “T가 비었나?”를 물으면 함정에 빠지기 쉽다.
type IsNever<T> = T extends never ? 'yes' : 'no';
type R = IsNever<never>; // 'yes' 가 아니라 → never ⚠️ (돌릴 멤버가 0개)
둘째, 튜플로 감싸면 분배가 꺼진다. 분배는 검사 자리가 naked일 때만 걸리므로(4·6절), [T]처럼 뭐로든 감싸면 naked가 풀려 union을 통째로 판정한다.
type IsNever<T> = [T] extends [never] ? 'yes' : 'no';
type R = IsNever<never>; // 'yes' ✅
여기서 방향을 정확히 하자. [T] extends [never]는 [T] ⊆ [never], 즉 T ⊆ never 를 묻는다. 그런데 공집합의 부분집합은 자기 자신뿐이라 — T ⊆ never는 T가 정확히 never일 때만 참이다. (never ⊆ T는 항상 참이지만, 방향이 반대이니 헷갈리지 말 것. 나도 여기서 자주 방향을 뒤집었다.)
[never] extends [never] // 참
['a' | 'b'] extends [never] // 거짓
그래서 [T] extends [never]는 “T가 비었나(never인가)?”를 판정하는 관용구다. 나중에 i18next가 “리소스가 하나도 정의 안 됐나(=키 union이 공집합인가)“를 검사하는 데 정확히 이 패턴을 쓴다.
튜플이 뭐냐면 — 길이가 고정되고 자리마다 타입이 정해진 배열이다.
string[]은 “string이 몇 개든”이고,[string, number]는 “정확히 2개, 1번째 string·2번째 number”(['age', 30]).[T]는 “T 하나 담은 상자”라, 분배를 끄려고 T를 감싸는 가장 가벼운 관용구다.
8. 키를 조립하는 도구 — keyof · 인덱스 접근 · 템플릿 리터럴 · infer
여기부터는 #3에서 “만들면서” 이미 다룬 도구들이라 빠르게 짚고 넘어간다. (자세한 트레이스는 #3 참고.)
keyof— 객체 타입의 키를 리터럴 union으로:keyof { a: 1; b: 2 }→'a' | 'b'- 인덱스 접근 — 값 꺼내기:
Keys['common']→ common 네임스페이스의 키들 - 템플릿 리터럴 타입 — 문자열 조립:
`${Ns}:${Key}`→'common:ok' infer— 조건부extends안에서 타입의 일부를 붙잡는 것 (정규식 캡처 그룹의 타입 버전)
type ElementOf<T> = T extends (infer X)[] ? X : never;
// "T가 '무언가의 배열'이면, 그 무언가를 X 로 붙잡아 반환"
ElementOf<string[]> // string
ElementOf<[1, 2, 3]> // 1 | 2 | 3 ← (아래 참조)
템플릿 리터럴 + union = 곱집합, 단 “네임스페이스별로”
템플릿 리터럴의 빈칸에 union을 넣으면 모든 조합(데카르트 곱)이 나온다.
type C = `${'a' | 'b'}-${'x' | 'y'}`; // 'a-x' | 'a-y' | 'b-x' | 'b-y' (2×2)
번역 키가 ns:key 형태면 이걸로 조립하는데 — 순진하게 ${모든_ns}:${모든_키}로 곱하면 범위가 너무 넓어진다. common엔 없는 키가 user엔 있을 때 common:그키까지 유효가 돼버린다. 그래서 곱은 네임스페이스별로(각 ns × 자기 키만) 일어나야 한다.
9. widening과 as const — 리터럴이 뭉개지는 함정
8절의 ElementOf<[1, 2, 3]>이 number가 아니라 1 | 2 | 3인 게 의아했다면 — 나도 그랬다 — widening(넓히기) 을 알아야 한다. 타입스크립트는 리터럴을 문맥에 따라 자동으로 베이스 타입으로 넓힌다.
let a = 'hello'; // string ← 넓혀짐 (재할당 가능하니)
const b = 'hello'; // 'hello' ← 리터럴 보존 (불변이니)
함정은 const여도 객체·배열 안은 넓혀진다는 것. 속 내용은 여전히 가변이기 때문이다.
const arr = [1, 2, 3]; // number[] ← 원소 넓혀짐 + 배열(튜플 아님)
그래서 상수 리스트에서 유효 키 union을 뽑으려면 as const로 넓히기를 꺼야 한다.
const arr2 = [1, 2, 3] as const; // readonly [1, 2, 3] (리터럴 보존 + 튜플 + readonly)
ElementOf2<typeof arr2> // 1 | 2 | 3
as const가 없으면 widening이 'ok' | 'cancel'을 string으로 뭉개서, 우리가 필요한 “구체적인 키 집합”이 사라진다.
widening ↔ narrowing 구분: widening은 추론 시점에 리터럴을 베이스로 넓히는 것(
'a'→string), narrowing은 실행 흐름 분석으로 union을 좁히는 것(typeof검사로string|number→string). 이름은 비슷하지만 완전히 다른 얘기다.
10. 종합 — i18next의 실제 타입 한 줄 읽기
이제 지금까지의 도구를 전부 동원해, i18next가 ns:key union을 만드는 실제 타입(공개 npm 패키지의 타입 정의)을 읽어보자. 이해를 위해 조금 단순화했다.
type ParseKeysByNamespaces<Ns, Keys> =
Ns extends readonly (infer UnionNsps)[] // ①
? UnionNsps extends keyof Keys // ②
? AppendNamespace<UnionNsps, Keys[UnionNsps]> // ③
: never // ④
: never; // ⑤
Keys는 “네임스페이스 → 그 ns의 키 union” 맵이라고 하자.
Keys = { common: 'ok' | 'cancel', user: 'name' | 'email' }
Ns = ['common', 'user']
- ①
Ns extends readonly (infer UnionNsps)[]— “Ns가 무언가들의 배열이냐?”(⊆ 판정) 맞으면 그 원소 타입을UnionNsps로 붙잡음(infer). →UnionNsps = 'common' | 'user' - ②
UnionNsps extends keyof Keys—UnionNsps가 naked union이라 분배(ns마다.map()). 각 ns가Keys의 실제 키냐를 확인하는 필터. - ③
AppendNamespace<UnionNsps, Keys[UnionNsps]>— 분배 덕에 여기UnionNsps는 지금 도는 ns 하나.Keys[UnionNsps](인덱스 접근)로 그 ns의 키만 꺼내, 템플릿 리터럴로ns:key곱을 만든다. - ④ ns가 없으면
never→ 분배 중이라 그 멤버만 증발(필터). - ⑤
Ns가 배열이 아니면 전체가never.
트레이스로 펼치면:
① 배열 → UnionNsps = 'common' | 'user'
② naked union → ns마다 분배:
┌ 'common': AppendNamespace<'common', 'ok'|'cancel'> → 'common:ok' | 'common:cancel'
└ 'user': AppendNamespace<'user', 'name'|'email'> → 'user:name' | 'user:email'
분배 결과 합침 → 'common:ok' | 'common:cancel' | 'user:name' | 'user:email'
정확히 “각 ns × 자기 키” 만 나온다. common:name 같은 교차 조합이 없는 건, ③의 Keys[UnionNsps]가 현재 ns의 키만 집기 때문(8절 오른쪽 그림).
이 다섯 줄에 이 글의 개념이 거의 다 들어있다:
| 코드 조각 | 개념 |
|---|---|
Ns extends readonly (…)[] | extends(⊆) + infer(캡처) |
UnionNsps extends keyof Keys ? … : never | 분배(ns마다 map) + never(필터) |
Keys[UnionNsps] | 인덱스 접근 (그 ns의 키만) |
AppendNamespace<…> | 템플릿 리터럴 곱집합 |
정리 — 그리고 다음 글
한 문장으로 줄이면 이렇다.
타입은 값의 집합이고,
extends는 부분집합(⊆) 판정이며, 조건부 타입은 그 판정으로 분기한다. 여기에 분배(union에 map)·never(필터/공집합)·튜플(분배 끄기)·infer(캡처)·템플릿 곱집합을 얹으면, i18next가 수백 개 키를 union으로 조립하는 타입을 스스로 읽을 수 있다.
- union = 합집합, 리터럴 키를 모아 하나의 집합으로.
- 대입 = ⊆: 좁은→넓은 OK, 넓은→좁은 ❌.
string이 거부되는 이유. extends= ⊆ (상속·제약·조건부 전부). 상속조차 “값 집합을 좁히는 것”.- 조건부 + 분배 +
never+ 튜플 = 타입 레벨의 map/filter, 그리고 그걸 켜고 끄는 스위치. - widening은 리터럴을 뭉개니, 키 union 재료엔
as const.
이 사고법이 잡히면 마지막 두 조각이 남는다. 동적 키(t(변수))가 왜 타입을 “과하게 막고” 어떻게 안전하게 좁히는가, 그리고 이 타입을 라이브러리에 꽂는 모듈 augmentation이 빌드 시 어떻게 평가되고 어디까지 전파되며 barrel export가 왜 다른 앱까지 오염시키는가 — 다음 글에서 판다.