합타입

합타입은 서로 다른 (하지만 제한적인) 유형의 값을 가질 수 있는 자료형입니다. 하나의 인스턴스는 이 유형 중 오직 하나에 속하며 보통 이들 유형을 구분하기 위한 "tag" 값이 존재합니다.

TypeScript 공식 문서에는 이들을 discriminated union 으로 부릅니다.

이 union 의 각 멤버들은 disjoint(서로소) 이어야 하며, 어떤 값이 두 개 이상의 멤버에 속할 수 없습니다.

예제

type StringsOrNumbers = ReadonlyArray<string> | ReadonlyArray<number>

declare const sn: StringsOrNumbers

sn.map() // error: This expression is not callable.

위 타입은 두 멤버가 빈 배열을 의미하는 [] 를 포함하기 때문에 서로소가 아닙니다.

문제. 다음 조합은 서로소인가요?

type Member1 = { readonly a: string }
type Member2 = { readonly b: number }
type MyUnion = Member1 | Member2

서로소 조합은 함수형 프로그래밍에서 재귀적입니다.

다행히 TypeScript 는 조합이 서로소임을 보장할 수 있는 방법이 있습니다: tag 로 동작하는 필드를 추가하는 것입니다.

참고: 서로소 조합, 합타입 그리고 태그된 조합은 같은 의미로 사용됩니다.

예제 (redux actions)

Action 합타입은 사용자가 todo app 에서 수행할 수 있는 작업의 일부를 모델링합니다.

type Action =
  | {
      type: 'ADD_TODO'
      text: string
    }
  | {
      type: 'UPDATE_TODO'
      id: number
      text: string
      completed: boolean
    }
  | {
      type: 'DELETE_TODO'
      id: number
    }

type 태그가 각 멤버가 서로소임을 보장하게 해줍니다.

참고. 태그역할을 하는 필드이름은 개발자가 선택하는 것입니다. 꼭 "type" 일 필요는 없습니다. fp-ts 에서는, 보통 _tag 필드를 사용합니다.

몇 개의 예제를 보았으니 이제 대수적 자료형을 보다 명확하게 정의할 수 있습니다:

일반적으로, 대수적 자료형은 하나 이상의 독립적 요소들의 합이며, 여기서 각 요소는 0개 이상의 필드의 곱으로 이루어져 있다.

합타입은 다형적이고 재귀적일 수 있습니다.

예제 (연결 리스트)

//               ↓ 타입 파라미터
export type List<A> =
  | { readonly _tag: 'Nil' }
  | { readonly _tag: 'Cons'; readonly head: A; readonly tail: List<A> }
//                                                              ↑ 재귀

문제 (TypeScript). 다음 자료형들은 곱타입 인가요? 합타입 인가요?

  • ReadonlyArray<A>
  • Record<string, A>
  • Record<'k1' | 'k2', A>
  • ReadonlyMap<string, A>
  • ReadonlyMap<'k1' | 'k2', A>

생성자

n 개의 요소를 가진 합타입은 각 멤버에 대해 하나씩 최소 n 개의 생성자 가 필요합니다:

예제 (redux action creators)

export type Action =
  | {
      readonly type: 'ADD_TODO'
      readonly text: string
    }
  | {
      readonly type: 'UPDATE_TODO'
      readonly id: number
      readonly text: string
      readonly completed: boolean
    }
  | {
      readonly type: 'DELETE_TODO'
      readonly id: number
    }

export const add = (text: string): Action => ({
  type: 'ADD_TODO',
  text
})

export const update = (
  id: number,
  text: string,
  completed: boolean
): Action => ({
  type: 'UPDATE_TODO',
  id,
  text,
  completed
})

export const del = (id: number): Action => ({
  type: 'DELETE_TODO',
  id
})

예제 (TypeScript, 연결 리스트)

export type List<A> =
  | { readonly _tag: 'Nil' }
  | { readonly _tag: 'Cons'; readonly head: A; readonly tail: List<A> }

// null 생성자는 상수로 구현할 수 있습니다
export const nil: List<never> = { _tag: 'Nil' }

export const cons = <A>(head: A, tail: List<A>): List<A> => ({
  _tag: 'Cons',
  head,
  tail
})

// 다음 배열과 동일합니다 [1, 2, 3]
const myList = cons(1, cons(2, cons(3, nil)))

Pattern matching

JavaScript 는 pattern matching 을 지원하지 않습니다 (TypeScript 도 마찬가지입니다) 하지만 match 함수로 시뮬레이션 할 수 있습니다.

예제 (TypeScript, 연결 리스트)

interface Nil {
  readonly _tag: 'Nil'
}

interface Cons<A> {
  readonly _tag: 'Cons'
  readonly head: A
  readonly tail: List<A>
}

export type List<A> = Nil | Cons<A>

export const match = <R, A>(
  onNil: () => R,
  onCons: (head: A, tail: List<A>) => R
) => (fa: List<A>): R => {
  switch (fa._tag) {
    case 'Nil':
      return onNil()
    case 'Cons':
      return onCons(fa.head, fa.tail)
  }
}

// 리스트가 비어있다면 `true` 를 반환합니다
export const isEmpty = match(
  () => true,
  () => false
)

// 리스트의 첫 번째 요소를 반환하거나 없다면 `undefined` 를 반환합니다
export const head = match(
  () => undefined,
  (head, _tail) => head
)

// 재귀적으로, 리스트의 길이를 계산해 반환합니다
export const length: <A>(fa: List<A>) => number = match(
  () => 0,
  (_, tail) => 1 + length(tail)
)

문제. head 가 최적의 API 가 아닌 이유는 무엇일까요?

참고. TypeScript 는 합타입에 대한 유용한 기능을 제공합니다: exhaustive check. Type checker 는 함수 본문에 정의된 switch 가 모든 경우에 대해 처리하고 있는지 검증 할 수 있습니다.

왜 "합"타입 이라 하는가?

왜냐하면 다음 항등식이 성립하기 때문입니다:

C(A | B) = C(A) + C(B)

합의 cardinality 는 각 cardinality 들의 합과 같습니다

예제 (Option 타입)

interface None {
  readonly _tag: 'None'
}

interface Some<A> {
  readonly _tag: 'Some'
  readonly value: A
}

type Option<A> = None | Some<A>

일반적인 공식인 C(Option<A>) = 1 + C(A) 를 통해, Option<boolean> 의 cardinality 를 계산할 수 있습니다: 1 + 2 = 3 개의 멤버를 가집니다.

언제 합타입을 써야하나요?

곱타입으로 구현된 각 요소가 의존적 일 때입니다.

Example (React props)

import * as React from 'react'

interface Props {
  readonly editable: boolean
  readonly onChange?: (text: string) => void
}

class Textbox extends React.Component<Props> {
  render() {
    if (this.props.editable) {
      // 오류: onChange 가 'undefined' 일 수 있어서 호출할 수 없습니다 :(
      this.props.onChange('a')
    }
    return <div />
  }
}

문제는 Props 가 곱타입으로 모델링되었지만, onChangeeditable의존 하는 것입니다.

이 경우에는 합타입이 더 유용합니다:

import * as React from 'react'

type Props =
  | {
      readonly type: 'READONLY'
    }
  | {
      readonly type: 'EDITABLE'
      readonly onChange: (text: string) => void
    }

class Textbox extends React.Component<Props> {
  render() {
    switch (this.props.type) {
      case 'EDITABLE':
        this.props.onChange('a') // :)
    }
    return <div />
  }
}

예제 (node callbacks)

declare function readFile(
  path: string,
  //         ↓ ---------- ↓ CallbackArgs
  callback: (err?: Error, data?: string) => void
): void

readFile 의 연산 결과는 callback 함수를 통해 전달되는 곱타입처럼 모델링됩니다 (정확히 말하면, tuple):

type CallbackArgs = [Error | undefined, string | undefined]

callback 요소들은 서로 의존적 입니다: Error 를 얻거나 또는 string 를 얻습니다:

errdatalegal?
Errorundefined
undefinedstring
Errorstring
undefinedundefined

이 API 는 다음과 같은 전제하에 모델링되지 않았습니다:

불가능한 상태를 나타낼 수 없게합니다

합타입이 더 좋은 선택입니다만, 어떤 합타입을 써야할까요? 이후 오류를 함수적인 방법으로 처리하는 방법을 다룰것입니다.

문제. 최근 callback 기반 API 들은 상당 부분 Promise 로 대체되고 있습니다.

declare function readFile(path: string): Promise<string>

TypeScript 같은 정적 타입 언어에서 Promise 를 사용할 때의 단점을 찾을 수 있나요?