TypeScript 21분 읽기

번역 키를 컴파일 타임에 잡기 #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(큰 원)은 유효 키 union(작은 원)의 상위집합이다. 리터럴 'close'(작은 원 안의 점)는 대입되지만, 오타 'user.titel'이나 'zzz'는 string 안이되 키 집합 밖이라 거부되고, string 타입 전체를 작은 원에 밀어 넣는 것도 불가능하다 — 넓은 건 좁은 데로 대입 불가

여기서 나도 처음에 헷갈렸던 것 하나. string이 거부되는 건 “리터럴 값이 안 맞아서”가 아니다. string은 애초에 특정 리터럴 값을 갖지 않는다. 런타임에 "zzz"가 될 수도 있어서 “유효 키 중 하나임을 보장할 수 없기 때문에” 통째로 거부되는 것이다. (반면 리터럴 'user.titel'은 값이 하나로 정해져 있으니, “그게 키 집합의 원소냐?”를 직접 따져 없으면 거부한다.)

3. extends = ⊆

이제 핵심 키워드. extends는 타입스크립트에서 “부분집합이다”를 나타내는 기호다.

A extends B = “A는 B의 부분집합” (A ⊆ B)

이게 세 자리에서 똑같은 뜻으로 쓰인다.

자리뜻 (전부 ⊆)
상속interface Dog extends AnimalDog는 Animal의 부분집합 (선언)
제네릭 제약<T extends Animal>T는 Animal의 부분집합이어야 (요구)
조건부 타입Dog extends Animal ? X : YDog가 Animal의 부분집합인가? (질문)

상속이 부분집합이라는 게 특히 반직관적이라 짚고 가자. 나도 한동안 반대로 알고 있었다.

interface Animal { name: string }
interface Dog extends Animal { bark(): void }   // name 상속 + bark 추가
  • Animal 집합 = “name만 있으면 되는 것” → 넓음
  • Dog 집합 = “name도 있고 게다가 bark도 있는 것” → 좁음

멤버(조건)를 추가하면 만족해야 할 게 늘어나 해당되는 값은 줄어든다. 그래서 Dog ⊆ Animal. “extends”라는 단어는 인터페이스(멤버 목록)를 확장한다는 뜻이지, 값 집합을 키운다는 뜻이 아니다 — 오히려 값 집합은 좁아진다. 그리고 이건 2절의 대입 규칙 그대로다: Dog(좁음)는 Animal(넓음) 자리에 들어가지만, 반대는 안 된다(Animalbark를 보장 못 하니까). 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'

분배는 union 'a'|'b'|'c' 를 멤버별로 쪼개 각각 조건(T extends 'b')을 돌린다. 'a'와 'c'는 조건 불충족이라 자기 자신을 반환하고, 'b'는 충족이라 never 를 반환해 결과 union에서 증발한다. 최종 결과는 'a' | 'c' — 분배와 never 반환을 합치면 필터가 된다

분배 = 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 × 자기 키만) 일어나야 한다.

왼쪽은 순진한 전체 곱 — common·user 두 네임스페이스와 ok·cancel·name·email 네 키를 통째로 곱해 8개 조합이 나오고, common-name, user-ok 같은 존재하지 않는 키까지 유효가 된다(빨강). 오른쪽은 네임스페이스별 곱 — common은 자기 키 ok·cancel과만, user는 name·email과만 조합해 유효한 4개만 남는다(초록). ns로 인덱싱하면 그 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|numberstring). 이름은 비슷하지만 완전히 다른 얘기다.

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 KeysUnionNspsnaked 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가 왜 다른 앱까지 오염시키는가 — 다음 글에서 판다.