-
TypeScript 초보자를 위한 Mapped Types 활용하여 깔끔한 인터페이스 만들기Javascript 2024. 9. 20. 18:44
TypeScript 초보자를 위한 Mapped Types 활용하여 깔끔한 인터페이스 만들기
안녕하세요 여러분! 오늘은 제가 구글링을 통해 알게 된 TypeScript의 Mapped Types에 대해 쉽게 풀어보려고 합니다.
프로그래밍을 막 시작한 분들도 이해할 수 있도록 최대한 친근하고 간단하게 설명해드릴게요.
TypeScript의 교차 타입(Intersection Type)이란?
TypeScript에는 교차 타입(Intersection Type)이라는 기능이 있는데요, 이는
T & U
와 같은 구문으로 표현됩니다.쉽게 말해, "T이면서 U인 타입"을 의미합니다.
구조적 부분 타입과 교차 타입의 이해
처음 들어보는 분들은 "교차 타입이 뭐야?"라고 궁금해할 수 있는데요.
실제로 교차 타입은 주로 객체 타입을 합칠 때 많이 사용됩니다.
예를 들어,
T
가{ foo: string }
타입이고U
가{ bar: number }
타입이라면,T & U
는{ foo: string; bar: number }
타입이 됩니다.type T = { foo: string }; type U = { bar: number }; type I = T & U; // 할당 가능 const obj: I = { foo: "foo", bar: 123 };
여기서
T & U
는 T와 U의 모든 프로퍼티를 가진 하나의 객체 타입으로 볼 수 있습니다.실제로 많은 경우에 이러한 직관적인 설명이 교차 타입을 이해하는 데 도움이 됩니다.
이 결과가 나오는 이유는 구조적 부분 타입(Structural Subtyping) 덕분인데요.
T
타입은 "문자열 타입의 프로퍼티foo
를 가진 객체"를 의미합니다.구조적 부분 타입의 특성상, 이 타입은
foo
외의 다른 프로퍼티에는 영향을 미치지 않습니다.따라서
{ foo: "foo", bar: 123 }
과 같은 객체도T
타입을 만족하게 됩니다.마찬가지로
U
타입도 "숫자 타입의 프로퍼티bar
를 가진 객체"를 의미합니다.결국,
T & U
는 "문자열 타입의 프로퍼티foo
를 가지며, 숫자 타입의 프로퍼티bar
도 가진다"는 조건을 모두 만족하는 타입입니다.{ foo: "foo", bar: 123 }
는 이러한 조건을 확실히 충족하죠.교차 타입과 제네릭(Generics)의 만남
이번에는 제네릭과 교차 타입을 결합해서 좀 더 흥미로운 예제를 만들어보겠습니다.
예를 들어, ImmutableRecord를 다루는
empty
함수와set
함수를 정의해보겠습니다.여기서는 구현은 생략하고 타입 정의만 진행할게요.
declare const recordBrand: unique symbol; type ImmutableRecord<Data> = { [recordBrand]: Data }; /** 새로운 빈 ImmutableRecord를 반환합니다. */ declare function empty(): ImmutableRecord<{}>; /** * 받은 ImmutableRecord에 새로운 키와 값을 추가하여 * 새로운 ImmutableRecord를 반환합니다. */ declare function set<Data, Key extends string, Value>( map: ImmutableRecord<Data>, key: Key, value: Value, ): ImmutableRecord<Data & { [K in Key]: Value }>; /** * 받은 ImmutableRecord에서 지정된 키의 값을 반환합니다. */ declare function get<Data, Key extends keyof Data>( map: ImmutableRecord<Data>, key: Key ): Data[Key]; // ImmutableRecord<{}> const m1 = empty(); // ImmutableRecord<{ foo: string }> const m2 = set(m1, "foo", "pikachu"); // ImmutableRecord<{ foo: string } & { bar: number }> const m3 = set(m2, "bar", 12345); // string const v1 = get(m3, "foo"); // number const v2 = get(m3, "bar");
empty
함수는 빈 레코드(ImmutableRecord<{}>
)를 생성하여 반환합니다.set
함수는 현재 레코드와 새로운 데이터를 받아서 이를 추가한 새로운ImmutableRecord
를 반환하며,get
함수는 지정된 키의 데이터를 가져옵니다.여기서
set
함수의 반환 타입에 교차 타입이 사용되었습니다.Data & { [K in Key]: Value }
는Data
와{ [K in Key]: Value }
를 합친 객체 타입을 의미합니다.오른쪽에 있는 것은 Mapped Type으로, 여기서는
Value
를 값으로 하는Key
라는 이름의 키를 하나만 가진 객체 타입을 표현하고 있습니다.덮어쓰기 문제와 해결 방법
하지만, 이런 구현에는 큰 문제점이 하나 있습니다.
바로 데이터 덮어쓰기를 표현할 수 없다는 것이죠. 동일한 키에 두 번
set
을 호출하면 이전 타입과 새로운 타입이 섞여버립니다.// ImmutableRecord<{}> const m1 = empty(); // ImmutableRecord<{ foo: string }> const m2 = set(m1, "foo", "pikachu"); // ImmutableRecord<{ foo: string } & { foo: number }> const m3 = set(m2, "foo", 12345); // never const v1 = get(m3, "foo");
여기서
m3
는ImmutableRecord<{ foo: string } & { foo: number }>
가 되는데, 이는foo
가string
타입이면서number
타입인string & number
타입을 의미합니다.문자열이면서 숫자인 값은 존재하지 않으므로, 이는
never
타입으로 해결됩니다. 따라서v1
의 타입이never
이 됩니다.이는 의도한 결과가 아니죠. 올바르게 작동하려면 같은 키에 두 번
set
을 호출할 때 첫 번째foo
의 타입은 버려져야 합니다.이를 구현하기 위해서는
Data
에서foo
를 제거한 후 새로운 타입을 추가해야 합니다./** * 받은 ImmutableRecord에서 Data에서 key를 제거하고, * 새로운 키와 값을 추가하여 새로운 ImmutableRecord를 반환합니다. */ declare function set<Data, Key extends string, Value>( map: ImmutableRecord<Data>, key: Key, value: Value, ): ImmutableRecord<Omit<Data, Key> & { [K in Key]: Value }>;
변경된 점은 반환 타입의 & 왼쪽이
Omit<Data, Key>
로 변경된 것입니다.이는
Data
에서Key
라는 이름의 키를 제거한 타입을 의미합니다.Omit
은 TypeScript의 표준 라이브러리에 포함되어 있습니다.이로써 직관적으로 동작하여,
v1
은number
타입이 됩니다.// ImmutableRecord<{}> const m1 = empty(); // ImmutableRecord<Pick<{}, never> & { foo: string }> const m2 = set(m1, "foo", "pikachu"); // ImmutableRecord<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }> const m3 = set(m2, "foo", 12345); // number const v1 = get(m3, "foo");
타입의 가독성 문제
하지만, 여기서 새로운 문제가 발생했습니다.
m3
의 타입을 확인해보면ImmutableRecord<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }>
라고 표시됩니다.연산의 결과는 실제로는
ImmutableRecord<{ foo: number }>
이어야 하지만,Pick
과&
가 포함된 복잡한 타입이 되어버립니다.이처럼 복잡한 연산을 하게 되면 타입이 보기 어려워지는 문제가 발생합니다.
타입은 코드를 읽는 사람을 도와주는 역할을 하기 때문에, 읽기 어려운 타입은 바람직하지 않습니다.
이러한 문제를 해결하는 방법 중 하나는 Flatten 타입을 사용하는 것입니다.
type Flatten<T> = { [K in keyof T]: T[K]; };
이 타입은 Mapped Type을 사용하여 객체 타입의 내부를 계산해주는 효과를 가집니다.
예를 들어, 단순한 교차 타입에도
Flatten
을 적용하면 더 깔끔한 하나의 객체 타입으로 만들 수 있습니다.type T = { foo: string } & { bar: number }; // type U = { foo: string; bar: number } type U = Flatten<T>;
이처럼, 두 개의 객체의 교차 타입에
Flatten
을 적용하면, 의미상 동일한 하나의 객체 타입이 되어 읽기 쉬워집니다.이를
set
함수의 반환 타입에 적용하면 결과가 더 깔끔해질 것 같습니다.구체적으로는 다음과 같습니다.
/** * 받은 ImmutableRecord에서 Data에서 key를 제거하고, * 새로운 키와 값을 추가한 후 Flatten을 적용하여 * 더 깔끔한 ImmutableRecord를 반환합니다. */ declare function set<Data, Key extends string, Value>( map: ImmutableRecord<Data>, key: Key, value: Value, ): ImmutableRecord<Flatten<Omit<Data, Key> & { [K in Key]: Value }>>;
이렇게 하면
m3
의 타입도 더 간결해집니다.// ImmutableRecord<{}> const m1 = empty(); // ImmutableRecord<Flatten<Pick<{}, never> & { foo: string }>> const m2 = set(m1, "foo", "pikachu"); // ImmutableRecord<Flatten<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }>> const m3 = set(m2, "foo", 12345); Flatten.png
하지만, 결과를 보면
m3
의 타입에Flatten
이 그대로 남아 있어 결과가 예상대로 깔끔해지지 않았습니다.이처럼,
Flatten
은 복잡한 타입에서는 기대한 효과를 발휘하지 못할 수 있습니다.자체 Mapped Type 사용하기
위의 문제를 해결하기 위해,
Flatten
이라는 이름을 사용하지 않고 함수의 반환 타입 내에서 직접 Mapped Type을 사용하는 방법이 있습니다./** * 받은 ImmutableRecord에서 Data에서 key를 제거하고, * 새로운 키와 값을 추가하는 Mapped Type을 사용하여 * 더 깔끔한 ImmutableRecord를 반환합니다. */ declare function set<Data, Key extends string, Value>( map: ImmutableRecord<Data>, key: Key, value: Value, ): ImmutableRecord< Key extends keyof Data ? { [K in keyof Data]: K extends Key ? Value : Data[K] } : { [K in keyof Data | Key]: K extends keyof Data ? Data[K] : Value }>;
이제,
m3
의 타입이 더 명확해졌습니다.// ImmutableRecord<{}> const m1 = empty(); // ImmutableRecord<{ foo: string }> const m2 = set(m1, "foo", "pikachu"); // ImmutableRecord<{ foo: number }> const m3 = set(m2, "foo", 12345); // number const v1 = get(m3, "foo");
이 정의를 통해
m3
의 타입이ImmutableRecord<{ foo: number }>
로 더 명확해졌습니다.결론
복잡한 타입 계산을 할 때는 결과 타입의 가독성을 신경 써야 합니다.
본 글에서 소개한 것처럼 함수의 반환 타입에 직접 Mapped Type을 사용하여 타입을 깔끔하게 유지하는 기술이 매우 유용합니다.
정확히는,
Key
가 유니언 타입일 때는 하나의 타입이 아닌 여러 타입이 될 수 있습니다.
'Javascript' 카테고리의 다른 글
Remix 쉽게 배우기: 플랫 파일 기반 라우팅 완벽 가이드 (1) 2024.09.21 React에서 콜백을 활용한 컴포넌트 분리 이해하기 (0) 2024.09.21 TypeScript에서 더블 어설션(Double Assertion) 이해하기: 안전하게 타입 변환 (0) 2024.09.20 구글링으로 찾은 TypeScript 초보자를 위한 안전한 스토리지 래퍼 활용법 (0) 2024.09.20 Next.js 14에서 JWT를 안전하게 관리하는 방법: Express 백엔드와의 통합 (1) 2024.09.19