유틸리티 타입 Roll

작성일:2024.07.17|조회수:0

유틸리티 타입 Roll

form 관리 라이브러리 sicilian을 만들면서 입력 이벤트를 타입 안전하게 다루기 위해 여러 타입을 정의했다. 그 과정에서 아래와 같은 Input<K> 타입을 만들었다.

TS
export type Input<K> =
  ChangeEvent<HTMLInputElement> & {
    target: { name: K; value: string };
  };

이 타입의 목적은 명확하다. handleChange 함수에 전달되는 이벤트 객체의 target.name이 반드시 특정한 K를 만족하도록 강제하는 것이다. 이를 통해 폼 필드 이름과 상태 키 사이의 불일치를 컴파일 타임에 차단할 수 있다.

다만 타입스크립트의 타입 힌트를 보면, 이 타입은 하나의 구조로 보이기보다는 두 개의 타입이 인터섹션된 형태로 추론된다. 즉, “의도적으로 하나의 입력 타입을 만들었다”는 느낌보다는, “여러 타입이 겹쳐진 결과”처럼 보인다. 타입 안정성에는 문제가 없지만, 타입을 읽는 입장에서는 다소 거슬리는 지점이다.

그러던 중 지인의 추천으로 네이버 사내 기술 교류 행사에서 발표된 타입스크립트 세션 영상을 보게 되었다. 다양한 테크닉이 소개되었는데, 그중 특히 눈에 들어온 것이 Roll이라는 유틸리티 타입이었다.

TS
type Roll<T> = { [K in keyof T]: T[K] } & {};

겉보기에는 단순한 타입이다. keyof T를 순회하면서 그대로 다시 매핑하고, 마지막에 & {}를 붙였다. 그런데 이 타입의 실제 동작은 꽤 흥미롭다.

객체 인터섹션 타입Roll<T>에 전달하면, 마치 처음부터 인터섹션이 아니었던 것처럼 “평탄화된 단일 객체 타입”으로 추론된다. 흔히 말하는 type flattening 또는 type normalization 효과를 얻을 수 있다.

TS
type A = { a: string };
type B = { b: number };

type AB = A & B;
type RolledAB = Roll<AB>;
// { a: string; b: number }

이 특성 덕분에, 여러 타입을 조합한 결과를 다시 “정리된 타입”으로 만들고 싶을 때 Roll은 매우 유용하다. 특히 라이브러리 코드를 작성하면서, 내부 구현에서는 인터섹션을 적극적으로 사용하되 외부에 노출되는 타입은 깔끔하게 유지하고 싶을 때 큰 도움이 된다.

그런데 여기서 한 가지 의문이 생긴다. Roll<T>의 정의를 보면 분명히 keyof T를 사용하고 있는데, 원시 타입을 넣었을 때도 문제가 없다는 점이다. 객체 타입이 아니라면 keyof T는 의미가 없어 보이는데, 왜 이런 결과가 나오는 걸까?

TS
type Test1 = Roll<string>;   // string
type Test2 = Roll<number>;   // number
type Test3 = Roll<"test">;   // "test"
type Test4 = Roll<1>;        // 1

이 동작을 이해하려면 타입스크립트에서 원시 타입이 어떻게 취급되는지를 알아야 한다. 타입스크립트에서 string, number, boolean 같은 원시 타입은 내부적으로 해당 타입의 wrapper object를 기반으로 동작한다. 예를 들어 keyof stringstring 타입이 가질 수 있는 메서드들의 키 집합이 된다.

TS
type KeysOfString = keyof string;
// "toUpperCase" | "toLowerCase" | ...

따라서 Roll<string>은 개념적으로는 다음과 같은 과정을 거친다.

  1. keyof string → string의 메서드 키 집합

  2. { [K in keyof string]: string[K] } → string 객체 타입을 그대로 재구성

  3. & {} → 타입을 한 번 더 평가

이 결과가 최종적으로 다시 string으로 축약된다. 타입스크립트는 “이 타입은 결국 string과 동일하다”고 판단하고 원래의 원시 타입으로 되돌려 버린다.

리터럴 타입도 마찬가지다.

TS
type Test3 = Roll<"test">; // "test"
type Test4 = Roll<1>;      // 1

리터럴 타입은 해당 원시 타입의 부분 집합이기 때문에, 재구성 이후에도 리터럴 정보가 보존된다. 즉, Roll<T>는 타입 정보를 확장하지도, 축소하지도 않고 있는 그대로 복사한 뒤 정규화하는 역할을 한다.

정리해보면 Roll<T>는 전달되는 타입의 형태에 따라 일관된 방식으로 타입을 재평가한다. 객체 인터섹션 타입을 전달할 경우에는 분산되어 있던 구조를 하나의 평탄화된 객체 타입으로 정리해주며, 반대로 원시 타입이나 리터럴 타입을 전달하면 기존 타입 정보를 전혀 훼손하지 않고 그대로 유지한다. 이로 인해 Roll<T>는 타입을 변형하기 위한 도구라기보다는, 현재 타입을 한 번 명시적으로 평가해 다시 꺼내기 위한 장치에 가깝다.

이러한 특성 때문에 Roll<T>는 단순히 “인터섹션 타입을 보기 좋게 만드는 유틸리티”라는 인상을 넘어선다. 실제로는 타입 복사를 통해 추론 결과를 고정하고, 타입 평가를 강제로 한 번 더 수행하며, 복잡하게 얽힌 타입을 정규화하는 역할을 수행한다. 특히 라이브러리 코드처럼 타입 조합이 잦고 외부로 노출되는 타입의 가독성이 중요한 환경에서는, Roll<T>가 타입 안정성과 표현력을 동시에 개선해주는 범용적인 유틸리티로 활용될 여지가 크다.

더 읽어보기

  • 2025.09.14

    Loose Autocomplete

    프로그래밍을 하다 보면 개발자가 의도한 선택지를 에디터가 자동완성으로 얼마나 잘 안내해 주느냐가 개발 경험에 큰 영향을 미친다는 사실을 자주 체감하게 된다. 특히 문자열 기반의 옵션을 다룰 때는 이 차이가 더욱 극명하게 드러난다.예를 들어 HTTP 메서드를 표현할 때 "GET" | "P…

  • 2025.04.25

    더 좁은 타입의 유효성에 대하여

    사람이 무언가를 집중해서 바라보다 보면, 어느샌가 주변부가 흐려지고 가끔은 집중하고 있던 그 대상조차 보이지 않게 된다. 처음엔 분명하게 인식되던 경계가 서서히 사라지고, 오히려 애써 무시했던 주변이 본질을 가릴 때도 있다. 잘 보려 애쓰는 행위가, 역설적으로 시야를 좁히는 순간이다.개…

  • 2025.04.06

    재귀 조건부 타입에서의 추론 컨텍스트 손실

    타입스크립트에서는 조건부 타입의 분배 과정에서도 타입 추론 컨텍스트가 유지된다. 이 특성 덕분에 단순한 분기 수준을 넘어, 상당히 복잡한 조건부 타입에서도 개발자가 의도한 방향으로 타입 추론을 유도할 수 있다. 실제로 이러한 특성은 고급 유틸리티 타입을 설계할 때 매우 강력한 도구로 작…

  • 2025.03.28

    유틸리티 타입 IsInRange

    프론트엔드 개발을 하다 보면 컴포넌트의 props로 number 타입을 받을 때가 많다. 하지만 단순히 number 타입만 지정하면 값의 범위를 제한할 수 없다는 점이 아쉽다. 예를 들어, 페이지네이션의 currentPage는 1 이상의 값만 허용해야 하고, 슬라이더의 value는 최소…

  • 2026.04.11

    Trie 자료구조

    문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…

  • 2026.03.19

    Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까

    웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…

댓글

댓글을 불러오는 중...