2025년 5월에 글을 업데이트 했습니다.
배경
프로젝트를 위한 디자인 시스템을 만들던 중, 색깔 이름을 props로 전달해서 컴포넌트에 적용해야 하는 요구사항이 생겼다. 우리의 색깔 테마 구조는 아래와 같이 생겼다.
const primitiveColor: PrimitiveColorType = {base: { white: '#FFFFFF', black: '#000000' },orange: {100: '#FDF0E7',200: '#FFD8BE',300: '#FFB17F',400: '#FF9958',500: '#E8742A',600: '#B55B21',},transparent: {white0: '#FFFFFF00',white10: '#FFFFFF1A',// 생략...},// 생략...} as const;const semanticColor: SemanticColorType = {primary: {subtle: primitiveColor.orange[100],default: primitiveColor.orange[400],strong: primitiveColor.orange[500],heavy: primitiveColor.orange[600],},background: {state: {danger: primitiveColor.red[100],warning: primitiveColor.yellow[100],info: primitiveColor.blue[100],success: primitiveColor.green[100],},// 생략...},} as const;const color: ColorType = {primitive: primitiveColor,semantic: semanticColor,} as const;
이렇게 객체 형태로 저장한 데이터에서 아래처럼 색깔을 찾아 컴포넌트에 적용할 수 있도록 props 타입을 정의해야 했다.
interface TextProps {className?: string;variant?: TypographyKey;color?: ColorTokenType;as?: ElementType;children: React.ReactNode;}function Text({className,variant = 'body1R',color,as = 'span',children,}: TextProps) {return (<S.Text as={as} className={className} $variant={variant} $color={color}>{children}</S.Text>);}
간단히 string으로 정의해도 당장 큰 문제가 발생하지 않을 수 있지만, 안정성도 챙기고 무엇보다 편리한 자동완성을 활용하기 위해서 색깔 토큰의 각 키를 dot notation으로 펼쳐서 표현할 수 있는 ColorTokenPath 유틸리티 타입을 만들어 사용하기로 했다.
이렇게 만든 결과와 사용 예시는 아래와 같다.
// 타입 정의export type ColorType = {primitive: PrimitiveColorType;semantic: SemanticColorType;};export type ColorTokenType = ColorTokenPath<ColorType>;// 사용 예시<Text variant="body1R" color="semantic.text.subtle">모또와 함께라면 정산 걱정 끝!</Text>
ColorTokenPath 유틸리티 타입 만들기
여러 디자인 시스템의 토큰 타입 정의를 살펴봤을 때에는 정적인 리터럴로 정의해 둔 곳이 많았는데, 우리는 아직 100% 완성된 디자인 시스템이 아니라 변경 가능성이 많고, 이미 색깔 테마 구조가 중첩이 많고 복잡하기 때문에 가능하면 색깔 테마 객체와 그 타입만 수정해도 전체 디자인 시스템에서 적용되는 색깔 토큰의 타입이 수정되도록 하고 싶었다. 이렇게 색깔 테마의 타입(ColorType)으로부터 새로운 타입을 만드는 것은 타입스크립트의 제네릭을 이용해서 할 수 있다.
타입스크립트에서는 제네릭을 이용해서 입력한 타입을 기반으로 새로운 타입을 생성할 수 있다. 제네릭이란 타입을 함수의 파라미터처럼 사용하는 것인데, 어떤 타입을 제네릭으로 전달하느냐에 따라서 유연한 결과를 만들 수 있다.
제네릭을 이용해서 만든 색깔 토큰 타입 ColorTokenPath는 아래와 같이 생겼다.
/*** @description* ColorTokenPath의 재귀 깊이를 줄이기 위한 유틸리티 타입*/type DepthDecrement = [never, 0, 1, 2, 3, 4, 5];/*** @description* ColorType의 키의 경로를 타입으로 변환하는 유틸리티 타입* @template T - 디자인 토큰 객체 타입* @template P - 현재까지의 경로 접두사* @template D - 최대 재귀 깊이 (기본값: 5)*/type ColorTokenPath<T,P extends string = '',D extends number = 5,> = D extends 0? never // 무한 루프를 방지하기 위해 재귀 깊이 제한에 도달하면 종료: T extends Record<string | number, unknown>? {[K in keyof T]: K extends string | number? T[K] extends Record<string | number, unknown> // 객체인 경우에는 재귀적으로 호출? ColorTokenPath<T[K], `${P}${K}.`, DepthDecrement[D]> // Decrement[D]로 깊이 감소: `${P}${K}` // 객체가 아닌 경우(leaf)에는 현재 키를 포함한 경로 반환: never; // K가 string | number가 아닌 경우는 무시}[keyof T]: never; // T가 객체가 아닌 경우는 무시
좀 복잡하게 생겼는데 ^_^;; 한 줄씩 살펴보자.
ColorTokenPath의 인자는 T (Type), P (Path), D (Depth) 로 총 3개이다. 그 중에서 P, D는 기본 값이 설정되어 있는데, 이 인자는 내부에서 재귀를 돌 때 직접 업데이트해서 전달하는 인자다. (내부적으로만 사용됨)
그리고 객체의 Key를 다루는 유틸리티 타입이기 때문에 T가 Record<string | number, unknown> 로 객체 타입인 경우에만 로직을 실행하도록 조건을 걸었다.
T extends Record<string | number, unknown>?// 여기서 로직을 실행...: never; // T가 객체가 아닌 경우는 무시
이제 객체의 Key를 펼치는 부분이다. 맵드 타입을 이용해서 T로 전달된 객체 타입을 순회해서 새로운 객체 타입을 만들었다.
맵드 타입은 기존 타입을 이용해서 새로운 타입을 만들 수 있는 방법이다. 자바스크립트의 map 함수와 비슷하게 기존 타입을 순회해서 새로운 타입을 만들 수 있다.
새로운 객체를 굳이 만들 필요는 없지만, 타입 선언 내에서는 for나 map 같이 내부 프로퍼티들을 직접 순회할 수 없기 때문에 맵드 타입으로 객체를 만들어 프로퍼티들을 순회하고, 생성된 객체의 값을 사용하는 방법을 썼다.
{[K in keyof T]: K extends string | number? T[K] extends Record<string | number, unknown> // 객체인 경우에는 재귀적으로 호출? ColorTokenPath<T[K], `${P}${K}.`, DepthDecrement[D]> // Decrement[D]로 깊이 감소: `${P}${K}` // 객체가 아닌 경우(leaf)에는 현재 키를 포함한 경로 반환: never; // K가 string | number가 아닌 경우는 무시}[keyof T]
SemanticColorType 이 T 로 전달된 경우를 예로 들어보면,
export type SemanticColorType = {primary: {subtle: string;default: string;strong: string;heavy: string;};state: {danger: string;warning: string;info: string;success: string;};};
맵드 타입 안에서 K는 SemanticColorType의 Key인 'primary' 와 'state' 가 된다. (K in keyof T) K가 'primary'라면 T[K] 는 아래 타입이 되고, 객체 타입이 된다.
{subtle: string;default: string;strong: string;heavy: string;}
우리가 필요한 색깔 토큰 타입은 가장 깊은 Key(코드에서는 leaf로 표현했다.)까지 포함하고 있는 문자열이기 때문에, T[K] extends Record<string | number, unknown> 조건으로 현재 위치의 타입이 객체인 경우에는 재귀 호출로 더 깊은 객체를 처리하도록 했다.
ColorTokenPath<T[K], `${P}${K}.`, DepthDecrement[D]>
T[K]를 전달해서 더 깊은 객체를 처리하도록 하고- P에
${P}${K}.를 전달해서 지금까지의 토큰 Path를 업데이트한다. DepthDecrement[D]는 무한 루프 에러를 방지하기 위한 부분인데, 아래에서 다시 설명한다.
K가 'subtle' 인 경우에는 T[K] 가 string으로 객체 타입이 아니다. 이 때는 더 처리할 필요가 없으므로 ${P}${K} 로 지금까지의 토큰 Path를 반환하도록 했다.
아까 맵드 타입으로 새로운 객체 타입을 만들었다. 만들어진 객체는 이런 모양인데,
{primary: 'primary.subtle' | 'primary.default' | 'primary.strong' | 'primary.heavy';state: 'state.danger' | 'state.warning' | 'state.info' | 'state.success'}
우리는 객체가 아닌 값들만 필요하기 때문에 최종적으로 만들어진 객체의 값만 타입으로 저장하도록 했다.
{// 객체 내부 생략...}[keyof T]
이렇게 하면 딱 필요하던 색깔 토큰 타입을 얻을 수 있다. 👏
type ColorTokenType =| 'primary.subtle'| 'primary.default'| 'primary.strong'| 'primary.heavy'| 'state.danger'| 'state.warning'| 'state.info'| 'state.success';
유틸리티 타입 재귀 깊이 제한
⁉️ 글을 쓰면서 다시 점검해보니
DepthDecrement[D]로 재귀 깊이를 확인하지 않아도 유틸리티 깊이 제한 에러가 발생하지 않는 것을 확인했다...리팩토링하면서 재귀 호출의 결과를 직접 참조하는 것에서 그렇지 않은 것으로 변경했는데, 확실하지는 않지만 아마도 이 차이로 인해서 에러가 발생하지 않는게 아닐까 싶다... 🤔
타입스크립트에서는 타입을 무한히 참조하는 상황을 방지하기 위해서 에러를 발생시킨다. (참고: ) [TS] 고급타입(Advanced Types) - 3) ColorTokenPath로 리팩토링 하기 전 사용하고 있었던 비슷한 로직의 FlattenKeys 타입에서도 이 에러가 발생했었다.

이 문제를 해결하기 위해서 재귀 깊이를 확인하기 위한 인자인 D를 추가하고, 재귀 깊이를 줄일 수 있는 타입인 DepthDecrement을 만들었다.
DepthDecrement 는 이렇게 생겼다.
/*** @description* ColorTokenPath의 재귀 깊이를 줄이기 위한 유틸리티 타입*/type DepthDecrement = [never, 0, 1, 2, 3, 4, 5];
지금 계속해서 다루고 있는 것들은 값이 아닌 타입이기 때문에 아무리 숫자라 하더라도 직접적인 감소 연산이 불가능하다. 따라서 배열을 사용해서 타입의 값을 하나씩 줄여주는 방법을 사용했다.
DepthDecrement[6] = 5DepthDecrement[5] = 4DepthDecrement[4] = 3DepthDecrement[3] = 2DepthDecrement[2] = 1DepthDecrement[1] = 0 // 재귀 종료ColorTokenPath<T[K], `${P}${K}.`, DepthDecrement[D]>
색깔 테마 객체의 깂이는 5 이상을 넘지 않을 것이기 때문에 깊이 제한을 5로 두었고, D가 '0'이 되면 재귀를 종료하도록 조건을 둬서 재귀 깊이 제한을 피할 수 있었다.
type ColorTokenPath<T,P extends string = '',D extends number = 5,> = D extends 0? never // 무한 루프를 방지하기 위해 재귀 깊이 제한에 도달하면 종료: // 로직 이어서...
마무리
정말 개인적인 생각으로는 타입스크립트는 유틸리티 타입 정도만 잘 쓴다면 '그래도 어디 가서 타입스크립트 잘 쓴다고 말할 수 있지 않을까?' 하는 생각을 하고 있었다. (정말 오만했다...) 그런데 이번에 유틸리티 타입을 직접 만들고 해결이 어려운 에러를 마주하면서 정말 큰 벽을 느꼈다는 생각이 들었다. 정말 공부란 끝이 없다...
색깔 타입을 선언하고, 동료가 편하게 디자인 컴포넌트를 사용하는 것을 보는 경험은 너무나도 즐거운 경험이었지만, 공부를 더 게을리 하지 말아야겠다는 다짐을 더 할 수 있는 계기 또한 되었다.