정의
값에서 타입을 파생한다는 건, 허용값 목록을 타입으로 한 번 값으로 또 한 번 적지 않고, 값 하나만 정답의 출처로 두고 타입을 거기서 계산해 내는 것을 말해요.
도구는 두 가지가 세트예요. 값에서 타입을 꺼내는 문법(indexed access, [number])과, 그 값이 넓어지지 않게 붙잡는 장치(as const, const 타입 파라미터)예요. 둘 중 하나만 쓰면 작동하지 않아요.
왜 필요한가
같은 사실을 두 곳이 소유하면 반드시 어긋나요. 표 컴포넌트의 컬럼 목록을 예로 들어볼게요.
const columns = [{ id: 'orderNo' }, { id: 'total' }];
type ColumnId = 'orderNo' | 'total'; // 손으로 적은 사본
이제 total 컬럼을 지운다고 해볼게요. 배열에서는 지웠는데 union은 그대로 남아 있으면, sort.columnId = 'total' 이라고 쓴 코드가 아무 경고 없이 통과해요. 실행하고 나서야 정렬이 안 먹는 걸 알게 돼요.
반대로 union만 고치고 배열을 안 고치면, 화면에는 컬럼이 남아 있는데 그걸로 정렬할 방법이 없어져요.
파생으로 바꾸면 컬럼을 지우는 순간 그 id로 정렬하던 모든 호출부가 컴파일 에러가 돼요. 이게 손으로 적은 union과의 결정적인 차이예요. 손으로 적은 union은 union만 고치고 사용처를 놓쳐요.
동작 원리
1단계: 값에서 타입 꺼내기
indexed access(인덱스 접근 타입) 는 객체 타입에서 속성 타입만 꺼내는 문법이에요. 점 표기법은 없고 항상 대괄호를 써요.
type User = { id: string; name: string };
type Id = User['id']; // string
[number] 를 배열 타입에 붙이면, 그 배열에 들어갈 수 있는 모든 원소 타입을 union으로 합쳐 줘요.
type Pair = [string, number];
type Element = Pair[number]; // string | number
keyof를 쓰면 안 되는 이유가 여기 있어요. keyof Pair는 '0' | '1' | 'length' | 'map' | ... 처럼 배열 자체의 속성까지 전부 나와요.
union에 indexed access를 씌우면 분배돼요. (A | B)['id']는 A['id'] | B['id']가 돼요. 이 세 가지를 합치면 원하는 게 나와요.
type ColumnIdOf<Cols extends readonly { id: string }[]> = Cols[number]['id'];
2단계: 값이 넓어지지 않게 붙잡기
여기서부터가 진짜예요. TypeScript는 값을 보고 타입을 정할 때 일부러 더 느슨한 타입으로 올려요. 이걸 widening(넓히기) 이라고 해요.
const a = 'orderNo'; // 'orderNo' ← 변수 자체는 리터럴 유지
const obj = { id: 'orderNo' }; // { id: string } ← ⚠️ 속성은 넓어져요
const cols = [{ id: 'a' }, { id: 'b' }]; // { id: string }[] ← 튜플도 리터럴도 아니에요
const로 선언했는데도 객체 속성은 넓어져요. 나중에 obj.id = '다른값' 을 할 수 있으니까 당연한 배려인데, 정확한 목록을 뽑고 싶을 때는 이게 치명적이에요.
넓어진 상태에서 아까 만든 파생을 돌리면 이렇게 돼요.
type Bad = (typeof cols)[number]['id']; // string
에러도 안 나고, 보호만 조용히 0이 돼요. 이게 이 패턴의 최대 함정이에요. 타입은 통과하고, 오타는 그대로 런타임까지 가요.
3단계: 막는 방법 두 가지
// (a) 호출부에서 as const
const cols = [{ id: 'orderNo' }, { id: 'total' }] as const;
// (b) const 타입 파라미터 (TypeScript 5.0+)
function defineColumns<const T extends readonly { readonly id: string }[]>(cols: T): T {
return cols;
}
const cols = defineColumns([{ id: 'orderNo' }, { id: 'total' }]);
(a)는 소비자가 as const를 빼먹으면 아무 경고 없이 무력화돼요. (b)는 함수 쪽이 강제하니까 빼먹을 수가 없어요. 공개 API라면 (b)를 골라요.
제약에 readonly를 반드시 넣어야 해요. const 추론 결과가 읽기 전용 튜플이라서, 제약이 그냥 배열이면 "읽기 전용을 쓰기 가능한 자리에 넣을 수 없다"로 컴파일이 실패해요.
실무 적용
타입 인자를 일부만 지정해야 할 때
행(Row) 타입은 사람이 직접 지정하고 컬럼 배열은 자동 추론시키고 싶은 상황이 자주 생겨요. 그런데 TypeScript는 타입 인자를 일부만 지정하는 문법이 없어요.
// ❌ Row만 주고 T는 추론시키는 게 안 돼요
function defineColumns<Row, const T extends readonly ColumnDef<Row>[]>(cols: T): T;
defineColumns<OrderRow>(cols); // T까지 요구받아요
함수를 두 겹으로 나눠서(커링) 계층을 분리해요.
function defineColumns<Row>() {
return <const T extends readonly ColumnDef<Row>[]>(cols: T): T => cols;
}
const cols = defineColumns<OrderRow>()([
{ id: 'orderNo', header: '주문번호' },
{ id: 'total', header: '합계' },
]);
type SortKey = ColumnIdOf<typeof cols>; // 'orderNo' | 'total'
같은 기법이 통하는 다른 자리
// 라우트 목록에서 경로 union 파생
function defineRoutes<const T extends readonly string[]>(paths: T): T { return paths; }
const routes = defineRoutes(['/home', '/settings']);
type Route = (typeof routes)[number]; // '/home' | '/settings'
// 객체 상수에서 키 union 파생
const THEME = { light: '#fff', dark: '#000' } as const;
type ThemeName = keyof typeof THEME; // 'light' | 'dark'
검증 방법
파생 결과 타입에 마우스를 올려 보세요. string이 보이면 어딘가에서 넓어진 거예요. 원인은 대개 둘 중 하나예요.
- 배열 리터럴을 변수에 한 번 담았다가 넘겼어요
- 이미 넓어진 배열을 spread로 합쳤어요 (
[...base, extra])
const 타입 파라미터는 호출부에 직접 쓴 리터럴만 살려요. 이미 string으로 넓어진 변수는 되살리지 못해요.
트레이드오프
파생이 항상 이기는 건 아니에요.
얻는 것은 정답이 한 곳에만 있다는 보장이에요. 컬럼을 지우면 관련 호출부가 전부 빨개지니까, 반쪽만 고치는 사고가 구조적으로 불가능해져요.
치르는 비용은 타입 시그니처가 읽기 어려워진다는 거예요. <const T extends readonly { readonly id: string }[]> 는 초보자가 보면 겁먹어요. 그리고 커링이 들어가면 호출 문법도 defineColumns<Row>()(cols) 처럼 낯설어져요.
목록이 짧고 잘 안 바뀌고 사용처가 한 파일 안에 있다면, 손으로 union을 적는 게 나아요. 판단 기준은 "이 목록을 고칠 때 다른 곳도 같이 고쳐야 하는가" 예요. 답이 예이면 파생하고, 아니면 그냥 적어요.
사용하면 안 되는 경우
- 목록이 런타임에 정해지는 경우. 서버 응답으로 컬럼이 내려온다면 컴파일 시점에 union을 만들 수 없어요. 이건 타입이 아니라 런타임 파서의 영역이에요.
- 소비자가 배열을 조립해서 넘기는 API. 배열을 변수로 만들거나 spread로 합쳐서 넘기는 사용 패턴이 주력이라면,
const타입 파라미터가 아무것도 못 살려요. 보호가 있는 척만 하게 돼요. - 한 곳에서만 쓰는 짧은 목록. 시그니처 복잡도가 얻는 안전보다 커요.
흔한 실수
- 파생 문법만 쓰고
const를 빼먹어요. 제일 흔하고 제일 조용한 실패예요. 결과가string이 되는데 에러는 안 나요. - 제약에
readonly를 빼먹어요. 이건 반대로 시끄럽게 실패해요. "readonly를 mutable에 할당할 수 없다"는 에러가 나요. - 배열 리터럴을 변수에 담았다가 넘겨요.
const raw = [...]; defineColumns(raw)는 이미 넓어진 뒤라 복구가 안 돼요. 호출 안에 직접 넣어야 해요. - 파생이 작동하는지 확인하지 않아요. 검증 질문은 하나예요. "이 컬럼을 지우면 어떤 호출부가 컴파일 에러가 되나요?" 답이 "아무 데도"라면 보호가 없는 거예요.
- hover만 보고 안심해요. 사람이 매번 hover로 확인하는 건 지속되지 않아요. 타입 테스트로 고정해 두는 게 맞아요.
관련 개념
- typescript-variance — 제약을 잡을 때 변성 판정에 기대면 문법 변화에 무너지는 이유
- generic-context-factory — 이 파생을 합성 컴포넌트의 조각들까지 전달하는 방법
- type-level-testing — 파생이 조용히 무력화되는 것을 잡아내는 유일한 수단
- single-source-of-truth-content-metadata — 같은 사실을 두 곳이 소유하면 어긋난다는 같은 원리