Search

Typescript 유틸리티 타입 만들기

SubCategory
category
Post
date
2025/02/17
description
객체 타입의 키를 dot notation으로 펼치는 유틸리티 타입을 만들어봤습니다.
1 more property
디자인 시스템의 프로퍼티 타입을 정의하기 위해서 객체의 프로퍼티들을 펼쳐서 유니온 타입을 만드는 유틸리티 타입을 만든 경험을 소개합니다.

배경

프로젝트를 위한 디자인 시스템을 만들던 중, 색깔 이름을 props로 전달해서 컴포넌트에 적용해야 하는 요구사항이 생겼습니다. 저희의 색깔 테마 구조는 아래와 같이 생겼는데요.
const primitive = { orange: { 100: '#ffd8be', 200: '#ffc59f', 300: '#ffaa73', // 생략... }, transparent: { black: { 0: '#00000000', 10: '#0000001a', }, }, // 생략... }
TypeScript
복사
const semantic = { primary: { subtle: PrimitiveColor.gray[50], default: PrimitiveColor.gray[600], strong: PrimitiveColor.gray[700], // 생략... }, state: { danger: PrimitiveColor.red[500], warning: PrimitiveColor.yellow[500], info: PrimitiveColor.blue[500], success: PrimitiveColor.green[500] }, // 생략... }
TypeScript
복사
이렇게 객체 형태로 저장한 데이터에서 색깔을 찾아 컴포넌트에 적용할 수 있도록 props 타입을 정의해야 했습니다. 아래처럼 말이죠!
interface TextProps { variant?: TypographyKey; color?: ColorKey; // 👈 as?: ElementType; children: React.ReactNode; } function Text({ variant = 'body1R', color, as = 'span', children }: TextProps) { return ( <S.Text as={as} $variant={variant} $color={color}> {children} </S.Text> ); }
TypeScript
복사
간단히 string으로 정의해두어도 당장 큰 문제가 발생하지 않을 수 있지만, 안정성도 챙기고 무엇보다 편리한 자동완성을 활용하기 위해서 객체의 키들을 dot notation으로 펼쳐서 표현할 수 있는 FlattenKey 유틸리티 타입을 만들어 사용하기로 했습니다. 이렇게 만든 결과와 사용 예시는 아래와 같아요.
// 타입 정의 export type ColorType = { primitive: PrimitiveColorType; semantic: SemanticColorType; }; export type ColorKey = FlattenKeys<ColorType>; // 사용 예시 <Text variant="body1R" color="semantic.text.subtle"> 모또와 함께라면 정산 걱정 끝! </Text>
TypeScript
복사

FlattenKey

타입스크립트에서는 제네릭을 이용해서 입력한 타입을 기반으로 새로운 타입을 생성할 수 있습니다.
제네릭이란 타입을 함수의 파라미터처럼 사용하는 것입니다. 어떤 타입을 제네릭으로 전달하느냐에 따라서 유연한 결과를 사용할 수 있어요.
저희의 경우에는 키를 펼칠 객체 타입을 전달해야 하는거죠. 이렇게 만든 FlattenKey 타입은 아래와 같습니다.
export type FlattenKeys<T> = { [K in keyof T & string]: T[K] extends Record<string, any> ? `${K}` | `${K}.${FlattenKeys<T[K]>}` : `${K}`; }[keyof T & string];
TypeScript
복사
1.
맵드 타입을 이용해서 T로 전달된 객체 타입을 순회해서 새로운 객체 타입을 만듭니다.
맵드 타입은 기존 타입을 이용해서 새로운 타입을 만들 수 있는 방법입니다. 자바스크립트의 map 함수와 비슷하게 기존 타입을 순회해서 새로운 타입을 만들 수 있습니다.
type FlattenKeys<T> = { [K in keyof T & string]: `${K}`; }
TypeScript
복사
semantic 객체가 T로 전달된 경우를 예로 들면 K 는 ‘primary’ , ‘state’ 가 됩니다.
객체로 만든 이유는 타입 선언 내에서는 for 같은 루프를 직접적으로 사용할 수 없기 때문에 맵드 타입으로 프로퍼티들을 순회해서 객체를 만든 뒤에 그 값을 사용하기 위함입니다.
& string 구문은 숫자를 제외한 문자열인 키만 사용하기 위해 추가된 구문입니다. ( 한계)
2.
T[K] extends Record<string, any> : 현재 키에 해당하는 값이 객체인지 확인합니다.
semantic.primary의 값은 객체이기 때문에 바로 타입으로 변환할 수 없습니다.
따라서 타입으로 변환할 수 있는 string 타입이 나올 때까지 재귀적으로 FlattenKeys 타입을 호출하는데요.
재귀 호출 여부를 판단하기 위해서 제약조건(extends)을 추가했습니다.
3.
객체인 경우 - `${K}` | `${K}.${FlattenKeys<T[K]>}` 객체가 아닌 경우 - `${K}`
현재 키를 저장하고 (’primary’) ( 한계) FlattenKeys를 재귀적으로 호출한 결과에 ‘K.’ prefix를 붙여줍니다. 이렇게 하면 재귀호출이 모두 완료되면 아래와 같은 결과가 나옵니다.
{ primary: 'primary' | 'primary.subtle' | 'primary.default' | 'primary.strong'; state: 'state' | 'state.danger' | 'state.warning' | 'state.info' | 'state.success' }
TypeScript
복사
4.
이렇게 만들어진 객체들의 값을 모두 합쳐 유니온 타입으로 만들어줍니다.
{ // 객체 내부 생략... }[keyof T & string] // 결과 type ColorType = 'primary' | 'primary.subtle' | 'primary.default' | 'primary.strong' | 'state' | 'state.danger' | 'state.warning' | 'state.info' | 'state.success';
TypeScript
복사

하지만 한계점이 있습니다…

아래 내용은 한계점이 발생한 원인은 알고 있지만 아직 해결하지 못했거나 프로젝트 코드에 반영하지 않은 내용을 담고 있습니다. 실제로 해결해서 프로젝트 코드에 반영한다면 이 글도 함께 업데이트 하겠습니다.
이렇게 FlattenKey 유틸리티 타입을 만들어서 유용하게 잘 썼는데요. 사실 해결하지 못했던 몇가지 한계점이 있습니다. 한계점이 발생한 지점은 위의 코드 설명에서  한계 로 표시해두었습니다. (조금 뜬금없었죠? ㅎㅎ)
1.
숫자가 아닌 문자열인 키만 사용하고 있다는 점 (해결 방법을 알고 있음)
이 부분은 사실 제 실수인데요. & string 구문으로 K의 타입을 제한하지 않으면 아래의 에러가 발생합니다.
Type 'keyof T' is not assignable to type 'string | number | bigint | boolean | null | undefined'. Type 'string | number | symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'
Plain Text
복사
symbol 타입을 템플릿 리터럴에 넣을 수 없기 때문에 발생하는 에러인데요. semantic 컬러만 생각하고 string 타입으로 제한을 두었는데 디자인 작업을 하다 보니 primitive 컬러에는 숫자 키를 사용하고 있다는 사실을 깨달았습니다. 그래서 지금은 primitive 컬러에 대한 타입은 제대로 반영되지 않은 상황입니다.
아래처럼 symbol 타입만을 제거하고 string 타입과 number 타입을 사용할 수 있도록 하면 이 문제는 해결할 수 있습니다.
export type FlattenKeys<T> = { [K in keyof T & (string | number)]: T[K] extends Record<string | number, any> ? `${K}` | `${K}.${FlattenKeys<T[K]>}` : `${K}`; // T[K]가 객체가 아닌 경우 키를 그대로 반환 }[keyof T & (string | number)];
TypeScript
복사
2.
중간 키 타입을 포함하고 있는 점 (해결 방법을 모름)
하위 객체의 키들을 가져오기 위해서 이렇게 재귀 호출을 하고 있는데요. 코드를 보면 ${K} 도 함께 타입으로 정의하고 있는 것을 알 수 있습니다.
`${K}` | `${K}.${FlattenKeys<T[K]>}`
TypeScript
복사
이 부분은 Type instantiation is excessively deep and possibly infinite 에러를 방지하기 위한 일종의 트릭이었는데요. 아래처럼 중간 타입을 정의하지 않으면 타입스크립트에서는 무한히 참조하는 상황을 방지하기 위해서 에러를 발생시킵니다. (참고: link iconTISTORY[TS] 고급타입(Advanced Types) - 3)
export type FlattenKeys<T> = { [K in keyof T & string]: T[K] extends Record<string, any> ? `${K}.${FlattenKeys<T[K]>}` // 👈 : `${K}`; }[keyof T & string];
TypeScript
복사
이 문제를 해결하기 위해서 재귀 깊이를 제한하는 방식을 적용할 수도 있지만 제네릭을 사용하고 있는 현재 상황에서는 적용하기 까다롭다고 생각했는데요. 해결 방식을 이리저리 찾다 GPT의 도움을 받아 중간 키도 함께 포함시켜 봤더니 에러가 발생하지 않아 이 방법을 적용해두었습니다. 하지만 어떤 원리로 해결된 것인지 모르기도 하고 (GPT는 컴파일러가 결과를 캐싱했기 때문에 무한 참조 에러가 발생하지 않는다고 설명함) 불필요한 타입이 남아있는 것이 너무 찜찜해서 한계점으로 남겨두었습니다.
혹시 어떤 이유로 이 문제가 해결된 것인지 알고 계신 분이나 좋은 해결 방법에 대한 아이디어가 있는 분이 계시다면 댓글 남겨주시면 정말 정말 감사드리겠습니다..

마무리

개인적으로는 타입스크립트는 유틸리티 타입 정도만 잘 쓴다면 그래도 어디 가서 타입스크립트 잘 쓴다고 말할 수 있지 않을까? 라는 생각을 하고 있었는데요. (정말 오만했죠…) 이번에 유틸리티 타입을 직접 만들고 해결이 어려운 에러를 마주하면서 큰 벽을 느낀 기분이 들었습니다. 공부란 정말로 끝이 없네요..
색깔 타입을 선언하고 팀 동료가 편하게 디자인 컴포넌트를 사용하는 것을 보는 경험은 너무나도 즐거운 경험이었지만, 공부를 더 게을리 하지 말아야겠다는 다짐을 더 할 수 있는 계기 또한 되었습니다.
FlattenKey가 적용된 MODDO가 궁금하다면?
모임 정산 서비스 link iconMODDO