[번역] TypeScript의 Discriminated Union에서 Omit 사용하기

SAM·2025년 11월 27일

TkDodo의 Omit for Discriminated Unions in TypeScript를 번역했습니다.


TypeScript에 객체의 타입 변환을 돕는 내장 유틸리티 타입들이 있다는 건 아마 다들 알고 계실 겁니다. Omit이나 Pick 같은 것들이죠.

하위 레벨의 기본 컴포넌트를 감싸는 특화된 React 컴포넌트를 만들 때, Omit은 Props의 타입을 정의하는 데 유용합니다. 구현사항을 하위 컴포넌트와 연결할 수 있거든요.

type SelectProps = {
  onChange: (value: string) => void
  options: ReadonlyArray<SelectOption>
  value: string | null
}

type UserSelectProps = Omit<SelectProps, 'options'>

Omit은 기본적으로 다음을 의미 합니다: "의존하고 있는 컴포넌트의 props에서 이것만(또는 여러 개) 빼고 원해." 그러면 props를 spread하고 빠진 것들은 직접 설정해서 UserSelect 컴포넌트를 만들 수 있습니다.

function UserSelect(props: UserSelectProps) {
  const users = useSuspenseQuery(usersQueryOptions)

  return <Select {...props} options={users.data} />
}

이 방식에는 두 가지 장점이 있습니다: wrapper 컴포넌트를 만들 때 SelectProps의 모든 필드를 다시 선언(즉, 복사)할 필요가 없고, 자동으로 서로 동기화도 됩니다. 이 의존성은 의도적입니다. Select{...props}로 UserSelect의 props를 spread하고 있으니 타입도 이에 맞게 동기화 되어있으니, Select에 필드를 추가하면 UserSelect도 자동으로 그 영향을 받게 됩니다.

물론 단점도 있습니다. 이런 타입들이 여러 레이어에 걸쳐 쌓이면 컴포넌트가 실제로 어떤 props를 받는지 파악하기 어려워질 수 있습니다. 저는 한 레이어 이상은 피하는 편입니다.

Discriminated Union 타입

Select에 새로운 기능을 추가해봅시다. clearable prop인데, 사용자가 현재 선택한 값을 해제할 수 있게 해줍니다. 그렇게 되면 onChangenull로 호출하고 싶을 겁니다. 타입의 첫 번째 초안은 이런식 일겁니다.

type SelectProps = {
  onChange: (value: string | null) => void
  options: ReadonlyArray<SelectOption>
  value: string | null
  clearable?: boolean
}

잘 작동하지만, 새로운 문제가 생깁니다. 기존의 모든 Select 사용처에서 onChange 핸들러가 null을 처리하지 않기 때문에 에러가 발생합니다. 런타임에서는 절대 null을 받지 않을 텐데도 말이죠. 명백히 clearable하지 않으니까요.
우린 타입 체커에게 이렇게 말하고 싶습니다. "clearable을 전달하면 onChangenull을 받을 수 있지만, 그렇지 않으면 null을 받지 않아." Discriminated Union이 이걸 도와줄 수 있습니다.

type BaseSelectProps = {
  options: ReadonlyArray<SelectOption>
  value: string | null
}

type ClearableSelectProps = BaseSelectProps & {
  clearable: true
  onChange: (value: string | null) => void
}

type UnclearableSelectProps = BaseSelectProps & {
  onChange: (value: string) => void
  clearable?: false
}

type SelectProps = ClearableSelectProps | UnclearableSelectProps

이전보다 복잡해 보이지만, 그만한 가치가 있습니다. 이제 TypeScript가 clearable 플래그로 union을 구별할 수 있습니다: true로 전달되면 onChange는 다른 구조를 받고, false로 전달되거나 전달되지 않으면 기존 구조를 받습니다. BaseSelectProps로 추출한 건 양쪽 union에 공통인 타입을 반복하지 않기 위해서입니다.

이제 새로운 clearable 기능이 타입 레벨에서도 하위 호환성을 가지게 되었으니, 배포해도 괜찮을 것 같습니다. 그런데 놀랍게도 CI에서 UserSelect 컴포넌트에 에러가 발생했습니다. 이런 식으로요:

Types of property 'clearable' are incompatible.
Type 'boolean | undefined' is not assignable to type 'false | undefined'.
Type 'true' is not assignable to type 'false'.(2345)

처음 읽었을 때 말이 안 된다고 생각했습니다 - 그냥 Omit으로 타입을 조합했을 뿐이고, 전에는 작동했거든요. 🤔 뭐가 바뀐 걸까요?

UserSelectProps가 이제 어떻게 확장되는지 확인하면 좀 더 이해가 되기 시작합니다:

type UserSelectProps = {
  onChange:
    | ((value: string | null) => void)
    | ((value: string) => void)
  value: string | null
  clearable?: boolean | undefined
}

clearable로 구별하던 union 타입이 사라졌습니다. Omit을 추가하자 기본적으로 모든 게 "펼쳐진" 겁니다. 분명 Omit의 버그겠지... 하지만 아닙니다, 의도된 동작입니다.

Omit은 각 union을 개별적으로 보지 않습니다(분배적이지 않습니다). union 전체를 하나로 취급하고 모든 멤버를 하나씩 매핑합니다. Ryan Cavanaugh어떤 이슈 댓글에서 말했듯이, Omit의 모든 가능한 정의에는 특정한 트레이드오프가 있고, 그들은 최선이자 일반적인 선택이라고 생각하는 것을 골랐습니다.

TypeScript 타입으로 Doom도 실행하는데 당연히 union을 파괴하지 않는 Omit 헬퍼를 작성하는 것도 가능할 것입니다. 다행히도 Distributive Conditional Type만 보면 됩니다.

조건부 타입(Conditional Types)

조건부 타입은 TypeScript가 테스트에 기반해 두 타입 중 하나를 선택하게 합니다. if 문의 타입 레벨 버전이죠:

T extends U ? X : Y

위 코드는 다음을 의미합니다: "T가 U에 할당 가능하면 타입 X를 사용하고, 아니면 타입 Y를 사용해."

Distributive Conditional Type

TypeScript 문서에 따르면 "조건부 타입이 제네릭 타입에 작용할 때, union 타입이 주어지면 이를 개별적으로 분리해 취급합니다." 즉, 조건문이 union의 각 멤버에 개별적으로 실행되는데, 이게 정확히 우리가 원하는 겁니다. 하지만 우리는 조건부 타입을 사용하지 않고 있습니다. 어떻게 해야 할까요?

Distributive Omit

타입이 T extends any ? ... : never인걸 본 적 있나요? 모든 건 any를 extends합니다. TypeScript의 최상위 타입이니깐요.

그게 바로 요점입니다. 조건문의 true 분기와 항상 매치되는 가짜 조건부 타입입니다. true 분기 안에 있는 것을 그냥 사용하는 것과 동일하지만 분배적(distributive, 개별적으로 분리되어 취급되는)이게 된다는 점이 다릅니다..

이걸 가지고 union과 더 잘 작동하는 Omit 헬퍼 타입을 만들 수 있습니다. 그냥 가짜 조건부 타입의 true 분기에서 Omit을 호출하면 됩니다:

type DistributiveOmit<T, K extends keyof T> = T extends any
  ? Omit<T, K>
  : never

UserSelectProps에 이걸 적용하면 어떻게 되는지 봅시다:

type UserSelectProps = DistributiveOmit<SelectProps, 'options'>

이 타입에 호버하면 다음처럼 확장됩니다:

type UserSelectProps =
  | Omit<ClearableSelectProps, 'options'>
  | Omit<UnclearableSelectProps, 'options'>

union 타입의 각 부분에 Omit이 적용된 게 명확히 보이고, UserSelect도 이제 암시적으로 clearable 기능의 이점을 얻게 됩니다 🎉

TypeScript Playground에서 이 솔루션을 가지고 놀아볼 수 있고, 같은 트릭을 Pick 같은 다른 헬퍼 타입에도 적용할 수 있다는 걸 참고하세요.

추가로, Omit에는 없고 우리의 DistributiveOmit 솔루션에는 있는 장점이 하나 더 있습니다.

Limited Keys

Omit의 타입 시그니처를 보면:

type Omit<T, K extends keyof any> = {
  [P in Exclude<keyof T, K>]: T[P]
}

K 타입 매개변수에 상한이 없다는 걸 알 수 있습니다(keyof any는 그냥 string | number | symbol로 확장됩니다). 이건 객체에 실제로 존재하지 않는 키를 전달할 수 있다는 뜻입니다. 실제로는 무해합니다. 존재하지 않는 걸 생략하는 건 아무것도 바꾸지 않으니까요.

하지만 분명 죽은 코드를 정리하는 측면에선 이점이 존재합니다. DistributiveOmit으로 전환했을 때(K extends keyof T를 사용), TypeScript가 갑자기 5개의 키를 Omit한 곳에 플래그를 띄웠는데, 확인해보니 생략한 5개 키 중 2개는 더 이상 존재하지 않더군요. 덕분에 죽은 코드를 정리할 수 있엇습니다 ✂️

profile
곰에서 사람으로 사람에서 곰으로

1개의 댓글

comment-user-thumbnail
2025년 11월 29일

좋은 글 잘 읽었습니다!

저의 경우 Omit을 만들 때 왜 K extends keyof T 로 안 하고 K extends keyof any로 했는지에 대한 궁금증이 있었는데 찾아보니 유연성을 위함이라고 하네요..!

답글 달기