blog.yoouyeon
about/tags
Back to blog
Feb 17, 2025

Typescript 유틸리티 타입 만들기

by 유연

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를 다루는 유틸리티 타입이기 때문에 TRecord<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]

SemanticColorTypeT 로 전달된 경우를 예로 들어보면,

export type SemanticColorType = {
primary: {
subtle: string;
default: string;
strong: string;
heavy: string;
};
state: {
danger: string;
warning: string;
info: string;
success: string;
};
};

맵드 타입 안에서 KSemanticColorType의 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]>

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] = 5
DepthDecrement[5] = 4
DepthDecrement[4] = 3
DepthDecrement[3] = 2
DepthDecrement[2] = 1
DepthDecrement[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 // 무한 루프를 방지하기 위해 재귀 깊이 제한에 도달하면 종료
: // 로직 이어서...

마무리

정말 개인적인 생각으로는 타입스크립트는 유틸리티 타입 정도만 잘 쓴다면 '그래도 어디 가서 타입스크립트 잘 쓴다고 말할 수 있지 않을까?' 하는 생각을 하고 있었다. (정말 오만했다...) 그런데 이번에 유틸리티 타입을 직접 만들고 해결이 어려운 에러를 마주하면서 정말 큰 벽을 느꼈다는 생각이 들었다. 정말 공부란 끝이 없다...

색깔 타입을 선언하고, 동료가 편하게 디자인 컴포넌트를 사용하는 것을 보는 경험은 너무나도 즐거운 경험이었지만, 공부를 더 게을리 하지 말아야겠다는 다짐을 더 할 수 있는 계기 또한 되었다.

end