본문으로 건너뛰기

커스텀 유틸리티 타입 만들기

Pick, Partial 같은 내장 유틸리티 타입만으로는 표현하기 힘든 상황에서, 직접 유틸리티 타입을 조합해서 만드는 방법을 정리합니다.

핵심 내용

기본 유틸리티 타입을 조합하면 표현하기 까다로운 요구사항도 타입 레벨에서 풀어낼 수 있습니다. 대표적인 예로 컴포넌트 props 중 스타일에만 관련된 것만 뽑아 쓰는 경우가 있습니다.

interface Props {
height?: string;
color?: keyof typeof colors;
isFull?: boolean;
onClick?: () => void; // 스타일과 무관한 prop
}

type StyledProps = Pick<Props, "height" | "color" | "isFull">;

const HrComponent = styled.hr<StyledProps>`
height: ${({ height }) => height || "10px"};
color: ${({ color }) => colors[color || "gray"]};
${({ isFull }) => isFull && css`margin: 0 -15px;`}
`;

전체 Props를 그대로 styled.hr<Props>에 넘겨도 동작은 하지만, onClick처럼 스타일과 무관한 prop까지 스타일 컴포넌트 타입에 섞이게 됩니다. Pick으로 스타일 관련 속성만 뽑아서 넘기면 "이 스타일 컴포넌트가 실제로 무엇에 반응하는지"가 타입에 그대로 드러납니다.

또 다른 대표 사례는 "여러 속성 중 정확히 하나만 있어야 하는" 타입입니다. 결제 수단이나 로그인 방식처럼, 여러 선택지 중 딱 하나만 골라야 하는 상황이 실무에서 은근히 자주 나옵니다.

type Card = { card: string };
type Account = { account: string };

function withdraw(method: Card | Account) {
// ...
}

withdraw({ card: "hyundai", account: "hana" }); // 에러가 안 남

Card | Account는 유니온이라 "둘 중 하나"를 뜻하는 것 같지만, 실제로는 합집합이라 두 속성을 동시에 넣어도 에러가 나지 않습니다. 이 문제를 풀려면 "정확히 하나만" 있어야 한다는 제약을 타입으로 직접 표현해야 합니다.

type PickOne<T> = {
[P in keyof T]: Record<P, T[P]> & Partial<Record<Exclude<keyof T, P>, undefined>>;
}[keyof T];

풀어서 보면 이렇습니다.

type One<T> = { [P in keyof T]: Record<P, T[P]> }[keyof T];
// T의 키 하나(P)를 골라 그 키만 값을 가진 타입을 만들고, 다시 keyof T로 결과를 합침

type ExcludeOne<T> = {
[P in keyof T]: Partial<Record<Exclude<keyof T, P>, undefined>>;
}[keyof T];
// 키 하나(P)를 뺀 나머지 키들을 전부 옵셔널 undefined로 만듦

type PickOne<T> = One<T> & ExcludeOne<T>;
// 합치면: 키 하나는 값을 가지고, 나머지는 undefined일 수 있는 타입
type PayMethod = PickOne<{ card: string; account: string }>;

function withdraw(method: PayMethod) {
// ...
}

withdraw({ card: "hyundai" }); // OK
withdraw({ card: "hyundai", account: "hana" }); // 에러

예시

NonNullable을 활용한 타입 가드 함수도 자주 쓰는 패턴입니다.

function isNonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}

const list = [1, null, 2, undefined, 3];
const filtered = list.filter(isNonNullable); // number[]로 좁혀짐

filter에 일반 화살표 함수((v) => v != null)를 넘기면 타입은 그대로 (number | null | undefined)[]로 남습니다. 반환 타입에 is를 명시한 타입 가드를 넘겨야 배열 전체의 타입이 좁혀집니다.

헷갈리기 쉬운 점

PickOne의 매핑 타입 부분([P in keyof T]: ...)이 두 번 등장해서 처음 보면 뭘 하는 건지 감이 안 옵니다. 하나씩 떼어놓고 보면, One<T>는 "이 속성이 있을 때"를 표현하고 ExcludeOne<T>는 "나머지 속성은 없을 때"를 표현합니다. 이 둘을 &로 합쳐야 "이거 하나만 있고 나머지는 없다"는 배타적 조건이 완성됩니다. 둘 중 하나만 있으면 원하는 제약이 안 걸립니다.

이 패턴은 discriminated union으로도 비슷하게 풀 수 있습니다. { type: "card"; card: string } | { type: "account"; account: string }처럼 구분 필드를 두는 방식입니다. PickOne은 기존 타입 구조를 그대로 두고 제약만 추가하고 싶을 때, discriminated union은 애초에 타입을 설계할 수 있을 때 어울립니다.

실무에서

배송지를 "집 주소" 또는 "직장 주소" 중 하나로만 받거나, 로그인 폼에서 "이메일" 또는 "전화번호" 중 하나만 필수로 받는 것처럼, API 요청 바디에서 여러 필드 중 정확히 하나만 채워야 하는 상황에 이 패턴이 맞습니다. 프론트엔드 유효성 검사 코드로만 강제하면 실수로 둘 다 채운 요청이 만들어질 수 있는데, 타입으로 강제하면 그 실수 자체가 컴파일 단계에서 막힙니다.

예상 질문

Card | Account 유니온이 왜 두 속성을 동시에 허용하나요?

유니온을 집합으로 생각하면 이해가 됩니다. Card | Account는 "Card의 조건을 만족하거나, Account의 조건을 만족하거나, 혹은 둘 다 만족하는" 집합입니다. 합집합이라 교집합(둘 다 만족하는 경우)도 포함됩니다. "정확히 하나만"이라는 배타적 조건은 유니온만으로는 표현이 안 되고, PickOne 같은 별도 타입이 필요합니다.