Typescript 유틸리티 타입 만들기

객체 타입의 키를 dot notation으로 펼치는 유틸리티 타입을 만들어봤습니다.

2025년 02월 17일| by유연

디자인 시스템의 프로퍼티 타입을 정의하기 위해서 객체의 프로퍼티들을 펼쳐서 유니온 타입을 만드는 유틸리티 타입을 만든 경험을 소개합니다.

2025년 5월에 글을 업데이트 했습니다.

배경

프로젝트를 위한 디자인 시스템을 만들던 중, 색깔 이름을 props로 전달해서 컴포넌트에 적용해야 하는 요구사항이 생겼습니다. 저희의 색깔 테마 구조는 아래와 같이 생겼는데요.

1
const primitiveColor: PrimitiveColorType = {
2
base: { white: '#FFFFFF', black: '#000000' },
3
orange: {
4
100: '#FDF0E7',
5
200: '#FFD8BE',
6
300: '#FFB17F',
7
400: '#FF9958',
8
500: '#E8742A',
9
600: '#B55B21',
10
},
11
transparent: {
12
white0: '#FFFFFF00',
13
white10: '#FFFFFF1A',
14
// 생략...
15
},
16
// 생략...
17
} as const;
18
19
const semanticColor: SemanticColorType = {
20
primary: {
21
subtle: primitiveColor.orange[100],
22
default: primitiveColor.orange[400],
23
strong: primitiveColor.orange[500],
24
heavy: primitiveColor.orange[600],
25
},
26
background: {
27
state: {
28
danger: primitiveColor.red[100],
29
warning: primitiveColor.yellow[100],
30
info: primitiveColor.blue[100],
31
success: primitiveColor.green[100],
32
},
33
// 생략...
34
},
35
} as const;
36
37
const color: ColorType = {
38
primitive: primitiveColor,
39
semantic: semanticColor,
40
} as const;

이렇게 객체 형태로 저장한 데이터에서 아래처럼 색깔을 찾아 컴포넌트에 적용할 수 있도록 props 타입을 정의해야 했습니다.

1
interface TextProps {
2
className?: string;
3
variant?: TypographyKey;
4
color?: ColorTokenType;
5
as?: ElementType;
6
children: React.ReactNode;
7
}
8
9
function Text({
10
className,
11
variant = 'body1R',
12
color,
13
as = 'span',
14
children,
15
}: TextProps) {
16
return (
17
<S.Text as={as} className={className} $variant={variant} $color={color}>
18
{children}
19
</S.Text>
20
);
21
}

간단히 string으로 정의해두어도 당장 큰 문제가 발생하지 않을 수 있지만, 안정성도 챙기고 무엇보다 편리한 자동완성을 활용하기 위해서 색깔 토큰의 각 키를 dot notation으로 펼쳐서 표현할 수 있는 ColorTokenPath 유틸리티 타입을 만들어 사용하기로 했습니다. 이렇게 만든 결과와 사용 예시는 아래와 같습니다.

1
// 타입 정의
2
export type ColorType = {
3
primitive: PrimitiveColorType;
4
semantic: SemanticColorType;
5
};
6
7
export type ColorTokenType = ColorTokenPath<ColorType>;
8
9
// 사용 예시
10
<Text variant="body1R" color="semantic.text.subtle">
11
모또와 함께라면 정산 걱정 끝!
12
</Text>

ColorTokenPath 유틸리티 타입 만들기

색깔 테마 구조가 이미 중첩이 많고 복잡하기 때문에 가능하면 색깔 테마 객체와 그 타입만 수정해도 전체 디자인 시스템에서 적용되는 색깔 토큰의 타입이 수정되도록 하고 싶었습니다. 그래서 색깔 테마의 타입 (ColorType)으로부터 새로운 타입을 만들어야 했고, 이것은 타입스크립트의 제네릭을 이용해서 할 수 있었습니다.

타입스트립트에서는 제네릭을 이용해서 입력한 타입을 기반으로 새로운 타입을 생성할 수 있는데요. 제네릭이란 타입을 함수의 파라미터처럼 사용하는 것입니다. 어떤 타입을 제네릭으로 전달하느냐에 따라서 유연한 결과를 사용할 수 있어요.

제네릭을 이용해서 색깔 토큰을 만드는 ColorTokenPath 유틸리티 타입은 아래와 같이 생겼습니다.

1
/**
2
* @description
3
* ColorTokenPath의 재귀 깊이를 줄이기 위한 유틸리티 타입
4
*/
5
type DepthDecrement = [never, 0, 1, 2, 3, 4, 5];
6
7
/**
8
* @description
9
* ColorType의 키의 경로를 타입으로 변환하는 유틸리티 타입
10
* @template T - 디자인 토큰 객체 타입
11
* @template P - 현재까지의 경로 접두사
12
* @template D - 최대 재귀 깊이 (기본값: 5)
13
*/
14
type ColorTokenPath<
15
T,
16
P extends string = '',
17
D extends number = 5,
18
> = D extends 0
19
? never // 무한 루프를 방지하기 위해 재귀 깊이 제한에 도달하면 종료
20
: T extends Record<string | number, unknown>
21
? {
22
[K in keyof T]: K extends string | number
23
? T[K] extends Record<string | number, unknown> // 객체인 경우에는 재귀적으로 호출
24
? ColorTokenPath<T[K], `${P}${K}.`, DepthDecrement[D]> // Decrement[D]로 깊이 감소
25
: `${P}${K}` // 객체가 아닌 경우(leaf)에는 현재 키를 포함한 경로 반환
26
: never; // K가 string | number가 아닌 경우는 무시
27
}[keyof T]
28
: never; // T가 객체가 아닌 경우는 무시

좀 복잡하게 생겼는데 ^_^;; 한줄씩 살펴보겠습니다.

ColorTokenPath의 인자는 T (Type), P (Path), D (Depth) 로 총 3개입니다. 그 중에서 P, D는 기본 값이 설정되어 있습니다. 내부에서 재귀를 돌 때 직접 업데이트해서 전달하는 인자예요. 그리고 객체의 Key를 다루는 유틸리티 타입이기 때문에 TRecord<string | number, unknown> 로 객체 타입인 경우에만 로직을 실행합니다.

1
T extends Record<string | number, unknown>
2
?
3
// 여기서 로직을 실행...
4
: never; // T가 객체가 아닌 경우는 무시

이제 객체의 Key를 펼치는 부분입니다. 맵드 타입을 이용해서 T로 전달된 객체 타입을 순회해 새로운 객체 타입을 만듭니다.

맵드 타입은 기존 타입을 이용해서 새로운 타입을 만들 수 있는 방법입니다. 자바스크립트의 map 함수와 비슷하게 기존 타입을 순회해서 새로운 타입을 만들 수 있습니다.

새로운 객체를 굳이 만들 필요는 없지만, 타입 선언 내에서는 for나 map 같이 내부 프로퍼티들을 직접 순회할 수 없기 때문에 맵드 타입으로 객체를 만들어 프로퍼티들을 순회하고, 생성된 객체의 값을 사용하는 방법을 사용했습니다.

1
{
2
[K in keyof T]: K extends string | number
3
? T[K] extends Record<string | number, unknown> // 객체인 경우에는 재귀적으로 호출
4
? ColorTokenPath<T[K], `${P}${K}.`, DepthDecrement[D]> // Decrement[D]로 깊이 감소
5
: `${P}${K}` // 객체가 아닌 경우(leaf)에는 현재 키를 포함한 경로 반환
6
: never; // K가 string | number가 아닌 경우는 무시
7
}[keyof T]

이런 SemanticColorTypeT 로 전달된 경우를 예로 들어보겠습니다.

1
export type SemanticColorType = {
2
primary: {
3
subtle: string;
4
default: string;
5
strong: string;
6
heavy: string;
7
};
8
state: {
9
danger: string;
10
warning: string;
11
info: string;
12
success: string;
13
};
14
};

맵드 타입 안에서 K는 SemanticColorType의 Key인 'primary''state' 가 됩니다. (K in keyof T) K'primary'라면 T[K] 는 아래 타입이 되고, 객체 타입이 됩니다.

1
{
2
subtle: string;
3
default: string;
4
strong: string;
5
heavy: string;
6
}

저희가 필요한 색깔 토큰 타입은 가장 깊은 key (코드에서는 leaf로 표현했습니다)까지 포함하고 있는 문자열이기 때문에 T[K] extends Record<string | number, unknown> 조건으로 현재 위치의 타입이 객체인 경우에는 재귀 호출로 더 깊은 객체를 처리하도록 했습니다.

1
ColorTokenPath<T[K], `${P}${K}.`, DepthDecrement[D]>
  • T[K] 를 전달해서 더 깊은 객체를 처리하도록 하고
  • P에 ${P}${K}. 를 전달해서 지금까지의 토큰 Path를 업데이트해줬습니다.
  • DepthDecrement[D] 는 무한 루프 에러를 방지하기 위한 부분인데, 아래에서 다시 설명하겠습니다.

K'subtle' 인 경우에는 T[K] 가 string으로 객체 타입이 아닙니다. 이 때는 더 처리할 필요가 없으므로 ${P}${K} 로 지금까지의 토큰 Path를 반환하도록 했습니다.

아까 맵드 타입으로 새로운 객체 타입을 만들었다고 했죠? 만들어진 객체는 이런 모양인데요.

1
{
2
primary: 'primary.subtle' | 'primary.default' | 'primary.strong' | 'primary.heavy';
3
state: 'state.danger' | 'state.warning' | 'state.info' | 'state.success'
4
}

저희는 객체가 아닌 값들만 필요하기 때문에 최종적으로 만들어진 객체의 값만 타입으로 저장하도록 했습니다.

1
{
2
// 객체 내부 생략...
3
}[keyof T]

이렇게 하면 딱 필요하던 색깔 토큰 타입을 얻을 수 있습니다. 👏

1
type ColorTokenType =
2
| 'primary.subtle'
3
| 'primary.default'
4
| 'primary.strong'
5
| 'primary.heavy'
6
| 'state.danger'
7
| 'state.warning'
8
| 'state.info'
9
| 'state.success';

유틸리티 타입 재귀 깊이 제한

⁉️ 글을 쓰면서 다시 점검해보니 DepthDecrement[D] 로 재귀 깊이를 확인하지 않아도 유틸리티 깊이 제한 에러가 발생하지 않는 것을 확인했습니다...

리팩토링하면서 재귀 호출의 결과를 직접 참조하는 것에서 그렇지 않은 것으로 변경했는데, 확실하지는 않지만 아마도 이 차이로 인해서 에러가 발생하지 않는게 아닐까 싶습니다... 🤔

타입스크립트에서는 타입을 무한히 참조하는 상황을 방지하기 위해서 에러를 발생시킵니다. (참고: [TS] 고급타입(Advanced Types) - 3) ColorTokenPath로 리팩토링 하기 전 사용하고 있었던 비슷한 로직을 가지고 있는 FlattenKeys 타입에서도 이 에러가 발생했는데요.

이 문제를 해결하기 위해서 재귀 깊이를 확인하기 위한 인자인 D를 추가하고, 재귀 깊이를 줄일 수 있는 타입인 DepthDecrement을 만들었습니다.

DepthDecrement 는 이렇게 생겼는데요.

1
/**
2
* @description
3
* ColorTokenPath의 재귀 깊이를 줄이기 위한 유틸리티 타입
4
*/
5
type DepthDecrement = [never, 0, 1, 2, 3, 4, 5];

지금 다루고 있는 것들은 값이 아닌 타입이기 때문에 직접적인 감소 연산이 불가능합니다. 따라서 배열을 사용해서 타입의 값(...)을 하나씩 줄여주는 방법을 사용했습니다.

1
DepthDecrement[6] = 5
2
DepthDecrement[5] = 4
3
DepthDecrement[4] = 3
4
DepthDecrement[3] = 2
5
DepthDecrement[2] = 1
6
DepthDecrement[1] = 0 // 재귀 종료
7
8
ColorTokenPath<T[K], `${P}${K}.`, DepthDecrement[D]>

색깔 테마 객체의 깊이는 5 이상을 넘지 않을 것이기 때문에 깊이 제한을 5로 두었고, D가 '0'이 되면 재귀를 종료하도록 조건을 둬서 재귀 깊이 제한을 피할 수 있었습니다.

1
type ColorTokenPath<
2
T,
3
P extends string = '',
4
D extends number = 5,
5
> = D extends 0
6
? never // 무한 루프를 방지하기 위해 재귀 깊이 제한에 도달하면 종료
7
: // 로직 이어서...

마무리

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

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