본문으로 건너뛰기

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

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 같은 별도 타입이 필요합니다.