TypeScript에서 Discriminated Unions을 위한 Omit

tkdodo.eu 블로그 포스트 번역


다양한 색상 배경의 기타 피크 - 노란색과 파란색

TypeScript에는 객체의 일반적인 타입 변환을 돕기 위한 내장 유틸리티 타입들이 있다는 것을 알고 있을 겁니다. OmitPick 같은 것들이죠.
더 낮은 수준의 기본 요소를 감싸는 특정 React 컴포넌트를 만들 때, OmitProps를 타입핑할 때 유용할 수 있습니다. 구현을 기본 요소와 연결할 수 있기 때문입니다.
SelectProps
type SelectProps = {
onChange: (value: string) => void
options: ReadonlyArray<SelectOption>
value: string | null
}

type UserSelectProps = Omit<SelectProps, 'options'>
기본적으로 이것은 다음을 의미합니다: 의존하는 컴포넌트의 모든 props를 원하는데, *이것 하나(또는 여러 개)는 제외하고 싶다는 뜻입니다. 그러면 props를 spread하고 빠진 것들을 직접 설정하여 UserSelect 컴포넌트를 만들 수 있습니다:
UserSelect
function UserSelect(props: UserSelectProps) {
const users = useSuspenseQuery(usersQueryOptions)

return <Select {...props} options={users.data} />
}
이것은 두 가지 장점이 있습니다: SelectProps의 모든 필드를 다시 선언(그리고 복사)할 필요가 없다는 것과, 자동으로 서로 동기화된다는 것입니다. 의존성은 의도적입니다: {...props}Select에 spread하고 있으므로, 타입도 이미 그것을 선언하고 있으며, Select에 필드를 추가하면 UserSelect도 그 이점을 누릴 수 있습니다.
단점도 있습니다: 이러한 타입들이 여러 계층에 걸쳐 쌓일 때 컴포넌트가 실제로 받는 props가 무엇인지 보기 어려워질 수 있습니다. 한 계층을 넘어가는 것은 피하겠지만, 일반적으로 이것은 좋은 패턴입니다 - SelectProps가 너무 복잡해지기 전까지는요.

Discriminated Union Types

Select에 새로운 기능을 추가해봅시다: 사용자가 현재 값을 선택 해제할 수 있게 해주는 clearable prop입니다. 그런 일이 발생하면, onChangenull로 트리거하고 싶을 겁니다. 타입의 첫 번째 초안은 다음과 같을 수 있습니다:
clearable
type SelectProps = {
onChange: (value: string | null) => void
options: ReadonlyArray<SelectOption>
value: string | null
clearable?: boolean
}
이것은 작동하지만, 새로운 문제를 야기합니다: 기존의 모든 Select 사용이 에러가 됩니다. 왜냐하면 그들의 onChange 핸들러들이 null을 처리하지 않기 때문입니다. 이것은 좋지 않습니다. 왜냐하면 그들은 런타임에 null을 받지 않을 것이기 때문입니다. 명확하게 clearable이 아니니까요.
우리는 정말로 타입 체커에 이렇게 말하고 싶습니다: "만약 clearable을 전달하면, onChangenull을 받을 수 있습니다. 그렇지 않으면 받지 않습니다". Discriminated Unions이 우리를 도와줄 수 있습니다:
Discriminated-Union-Type
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로 전달되면, onChangefalse로 전달되거나 전혀 전달되지 않을 때와 다른 구조를 갖게 됩니다. 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가 이제 무엇으로 확장되는지 검사했을 때 더 이해가 되기 시작했습니다:
Expanded-UserSelectProps
type UserSelectProps = {
onChange:
| ((value: string | null) => void)
| ((value: string) => void)
value: string | null
clearable?: boolean | undefined
}
Union 타입이 구분되는 것이 사라졌습니다 - Omit을 추가하는 것은 기본적으로 모든 것을 "확장"했습니다. 확실히 Omit의 버그처럼 보이지만, 아닙니다. 이것은 의도된 동작입니다.
Omit은 각 union을 개별적으로 보지 않습니다 (이것은 distributive하지 않습니다). 전체 union을 취급하고 모든 멤버를 하나씩 매핑합니다. Ryan Cavanaugh이슈 댓글 중 하나에서 말했듯이, Omit의 모든 가능한 정의는 특정 트레이드오프를 가지고 있으며, 그들은 최고의 일반적인 적합이라고 생각하는 것을 선택했습니다.
만약 TypeScript Types에서 Doom을 실행할 수 있다면, 우리의 union을 파괴하지 않는 Omit 헬퍼를 작성할 수 있어야 하고, 운 좋게도, 우리는 Distributive Conditional Types보다 더 멀리 볼 필요가 없습니다.

Distributive Conditional Types

TypeScript 문서에서 말하듯이: "조건부 타입이 generic 타입에 작용할 때, union 타입이 주어지면 distributive가 됩니다." 다시 말해, 조건부는 union의 각 멤버에 개별적으로 실행되며, 이것이 정확히 우리가 원하는 것입니다. 하지만 우리는 조건부 타입을 가지고 있지 않으며, 정말로 원하지도 않습니다. 그래서 이것은 어디로 가는 걸까요?

Distributive Omit

T extends any ? ... : never라고 말하는 타입을 본 적이 있고 궁금해한 적이 있나요: 왜 그렇게 할까요? 명백하게, 모든 것이 any를 확장합니다 - 이것은 TypeScript의 top 타입입니다.
네 - 맞습니다. 그리고 그것이 정확히 요점입니다. 이것은 조건의 true 분기를 항상 일치시킬 fake 조건부 타입입니다. 이것은 단지 true 분기 내부에 있는 것을 사용하는 것과 동등해야 하지만, 이제 distributive가 됩니다.
이를 통해, 우리는 단순히 그러한 fake 조건부 타입의 true 분기에서 Omit을 호출하여 우리의 union과 더 잘 작동하는 Omit 헬퍼 타입을 만들 수 있습니다:
DistributiveOmit
type DistributiveOmit<T, K extends keyof T> = T extends any
? Omit<T, K>
: never
이것을 우리의 UserSelectProps에 적용하면 어떻게 되는지 봅시다:
New-UserSelectProps
type UserSelectProps = DistributiveOmit<SelectProps, 'options'>
이 타입 위에 마우스를 올리면, 다음과 같이 확장됩니다:
New-Expanded-UserSelectProps
type UserSelectProps =
| Omit<ClearableSelectProps, 'options'>
| Omit<UnclearableSelectProps, 'options'>
이것은 명확하게 예상대로 작동함을 보여줍니다: Omit이 우리의 union 타입의 각 부분에 적용되며, 우리의 UserSelect는 이제 clearable 기능으로부터 암묵적으로 이점을 누릴 것입니다.🎉
이 솔루션을 가지고 놀고 싶다면 TypeScript Playground를 확인해보세요. 그리고 Pick 같은 다른 헬퍼 타입에도 같은 트릭을 적용할 수 있다는 것을 주목하세요.
추가로, 우리가 우리의 DistributiveOmit 솔루션에 코딩한 또 다른 이점이 있습니다. 일반적인 Omit은 이것을 가지고 있지 않습니다:

Limited Keys

Omit의 타입 시그니처를 보면:
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개의 키를 생략하고 있는 곳들을 플래그했습니다. 심지어 그 중 2개는 더 이상 존재하지 않았는데요.
그들은 아마도 어느 시점에 존재했고 정리 중에 그냥 남겨졌을 겁니다. 그리고 당신이 나를 안다면, 당신은 내가 dead code의 팬이 아니라는 것을 알 것입니다. 그래서 이것은 정리할 좋은 작은 기회가 되었습니다. ✂️


오늘은 여기까지입니다. 질문이 있으시면 bluesky에서 나에게 연락하거나, 아래에 댓글을 남겨주세요. ⬇️
코드 블록의 monospace 폰트가 마음에 드나요?
0
5

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글