Option 타입
특수 목적 데이터 타입을 사용해 예외를 표현하는 방법도 있습니다.
이 방식은 값과 에러의 유니온을 반환하는 방법에 비해 단점이 있지만, 에러가 발생할 수 있는 계산에 여러 연산을 연쇄적으로 수행할 수 있게 됩니다.
가장 많이 사용되는 세 가지로 Try, Option, Either 타입이 있습니다.
Try, Option, Either 데이터 타입은 Array, Error, Map, Promise 등과는 달리 JavaScript가 기본적으로 제공하지 않습니다.
따라서 이 타입들을 사용하려면 NPM에서 찾아 설치하거나 직접 구현해야 합니다.
Option 타입은 하스켈, 오캐멀, 스칼라, 러스트(Rust) 등의 언어에서 가져온 개념입니다.
어떤 특정 값을 반환하는 대신 값을 포함하거나 포함하지 않을 수도 있는 컨테이너를 반환한다는 것이 Option 타입의 핵심입니다.
컨테이너는 자체적으로 몇 가지 메서드를 제공하며, 개발자는 이를 이용해 안에 값이 없을지라도 여러 가지 연산을 연쇄적으로 수행할 수 있습니다.
값을 포함할 수 있다면 어떤 자료구조도 컨테이너를 구현할 수 있습니다.
예를 들어 다음은 배열로 구현한 모습입니다.
function ask() {
return prompt("생일 언제임?");
}
function parse(birthday: string): Date[] {
let date = new Date(birthday);
if (!isValid(date)) {
return[]
}
return [date];
}
let date = parse(ask())
date
.map(_ => _.toISOString())
.forEach(_ => console.info('날짜는', _))
언제든 실패할 수 있는 여러 동작을 연쇄적으로 수행할 때 Option의 진가가 발휘됩니다.
예를 들어 prompt는 항상 성공하고, parse는 실패할 수 있다고 가정해 보겠습니다.
그런데 사실은 prompt도 실패할 수 있다면 어떻게 될까요?
사용자가 생일 입력을 취소하면 에러가 발생할 것이고 프로그램은 계산을 이어갈 수 없게 됩니다.
이때 또 다른 Option을 이용해 이 상황을 처리할 수 있습니다.
function ask() {
let result = prompt("생일 언제임?");
if(result === null){
return []
}
}
function parse(birthday: string): Date[] {
let date = new Date(birthday);
if (!isValid(date)) {
return[]
}
return [date];
}
ask()
.map(parse)
.map(date=> date.toISOString()) // 'toISOString' 속성이 'Date[]' 형식에 없습니다. 'toString'을(를) 사용하시겠습니까?ts(2551)
.forEach(date => console.info('날짜는', date))
뭔가 잘못됐습니다.
Date의 배열(Date[])을 Date의 배열의 배열(Date[][])로 매핑했기 때문인데, 이 문제는 Date의 배열로 flatten 해서 해결할 수 있습니다.
function ask() {
let result = prompt("생일 언제임?");
if(result === null){
return []
}
}
function parse(birthday: string): Date[] {
let date = new Date(birthday);
if (!isValid(date)) {
return[]
}
return [date];
}
flatten(ask()
.map(parse))
.map(date=> date.toISOString())
.forEach(date => console.info('날짜는', date))
// 배열의 배열을 배열로 flatten
function flatten<T>(array:T[][]): T[] {
return Array.prototype.concat.apply([], array)
}
통제하기가 조금 힘들어졌습니다.
이 코드에선 타입이 많은 정보를 제공하지 않으므로 무슨 일이 벌어지고 있는지를 한눈에 파악하기 어렵습니다.
여기서 컨테이너란 특수한 데이터 타입에 담아서 상황을 개선해 보겠습니다.
이때 컨테이너는 대상 값을 이용해 연산을 수행하는 방법과 그 결과를 얻어내는 방법을 드러내는 역하릉 합니다.
컨테이너를 다 구현하고 나면 다음처럼 사용할 수 있게 됩니다.
ask()
.flatMap(parse)
.flatMap(date => new Some(date.toISOString()))
.flatMap(date => new Some('날짜는 ', date))
.getOrElse('날짜 잘못 쓴 것 같슴다.')
Option 타입은 아래처럼 정의할 것입니다.
- Option은 Some<T>와 None이 구현하게 될 인터페이스입니다. 이 두 클래스는 모두 Option의 한 형태가 됩니다. Some<T>는 T라는 값을 포함하는 Option이고 None은 값이 없는, 즉 실패한 상황의 Option을 가리킵니다.
- Option은 타입이기도 하고 함수이기도 합니다. 타입 관점에선 단순히 Some과 None의 슈퍼 타입을 뜻합니다. 함수 관점에선 Option 타입의 새 값을 만드는 기능을 뜻합니다.
먼저 타입들의 밑그림을 그려보겠습니다.
interface Option<T>{} // ①
class Some<T> implements Option<T>{ // ②
constructor(private value: T){}
}
class None implements Option<never>{} // ③
- Option<T>는 Some<T>와 None을 공유하는 인터페이스입니다.
- Some<T> 연산에 성공하여 값이 만들어진 상황을 나타냅니다. 앞서 사용했던 배열처럼 Some<T>는 결괏값으로 표시됩니다.
- None은 연산이 실패한 상황을 나타내며, 따라서 값을 담고 있지 않습니다.
앞선 배열 기반 구현과 비교하면 아래와 같습니다.
- Option<T>는 [T] | []
- Some<T>는 [T]
- None은 []
이 Option으로 무엇을 할 수 있을까요?
활용법을 간단히 보여주기 위해 두 가지 연산만 정의해 보겠습니다.
- flatMap
- 비어있을 수도 있는 Option에 연산을 연쇄적으로 수행하는 수단입니다.
- getOrElse
- Option에서 값을 가져옵니다.
Option 인터페이스에 이들 연산을 정의합니다.
그러려면 Some<T>와 None에서 구체적인 코드를 구현해야 합니다.
interface Option<T>{
flatMap<U>(f: (value: T)=> Option<U>): Option<U>
getOrElse(value: T): T
}
class Some<T> extends Option<T>{
constructor(private value: T){}
}
class None extends Option<never>{}
이어서 코드의 의미를 살펴보겠습니다.
- flatMap은 T 타입의 값을 받는 f 함수를 인수로 받아 U 타입의 값을 포함하는 Option을 반환합니다. flatMap은 Option을 인수로 건네는 f를 호출한 다음 새로운 Option<U>를 반환합니다.
- getOrElse는 Option이 포함하는 값과 같은 타입인 T 타입의 값을 기본 값으로 받은 다음, Option이 빈 None이면 기본값을 반환하고, Option이 Some<T>이면 Option 안의 값을 반환합니다.
동작을 고려해 Some<T>와 None에 메서드를 구현해 넣어보겠습니다.
interface Option<T>{
flatMap<U>(f: (value: T)=> Option<U>): Option<U>
getOrElse(value: T): T
}
class Some<T> implements Option<T>{
constructor(private value: T){}
flatMap<U>(f: (value: T) => Option<U>): Option<U> { // ①
return f(this.value)
}
getOrElse(value: T): T { // ②
return this.value
}
}
class None implements Option<never>{
flatMap<U>(): Option<U> { // ③
return this
}
getOrElse<U>(value: U): U { // ④
return value
}
}
- Some<T>에 flatMap을 호출하면 인수로 전달된 f를 호출해 새로운 타입의 새 Option을 만들어 반환합니다.
- Some<T>에 getOrElse를 호출하면 Some<T>의 값을 반환합니다.
- None은 계산 실패를 의미하므로 flatMap을 호출하면 항상 None을 반환합니다. 계산이 한 번 실패하면 회복될 수 없기 때문입니다.
- None에 getOrElse를 호출하면 항상 기본값으로 제공한 값을 그대로 반환합니다.
이 코드는 기본적인 구현일 뿐이므로 조금 더 개선할 수 있습니다.
T를 Option<U>로 바꿔 반환하는 함수와 Option이 갖고 있는 전부라면 Option<T>의 flatMap은 항상 Option<U>를 반환할 것입니다.
하지만 Some<T>와 None까지 준비되면 더 구체적으로 표현할 수 있습니다.
아래의 표는 Option이 두 타입에 flatMap을 호출했을 때 결과 타입을 보여줍니다.
Some<T>로부터 | None으로부터 | |
Some<U>로 | Some<U> | None |
None으로 | None | None |
None의 매핑 결과는 항상 None이며 Some<T>의 매핑 결과는 f 호출 결과에 따라 Some<T>나 None이 된다는 사실을 알 수 있습니다.
이 사실을 이용해 flatMap이 조금 더 구체적인 타입을 제공하도록 시그니처를 오버로드할 수 있습니다.
interface Option<T>{
flatMap<U>(f: (value: T) => None): None
flatMap<U>(f: (value: T)=> Option<U>): Option<U>
getOrElse(value: T): T
}
class Some<T> implements Option<T>{
constructor(private value: T){}
flatMap<U>(f: (value: T) => None): None
flatMap<U>(f: (value: T) => Some<U>): Some<U>
flatMap<U>(f: (value: T) => Option<U>): Option<U> {
return f(this.value)
}
getOrElse(value: T): T {
return this.value
}
}
class None implements Option<never>{
flatMap<U>(): None {
return this
}
getOrElse<U>(value: U): U {
return value
}
}
필요한 기능을 거의 다 구현했습니다.
이제 새 Option을 만드는 데 사용할 함수를 구현하는 일만 남았습니다.
Option 타입을 인터페이스로 정의했으므로 이 함수의 이름도 똑같이 짓도록 하겠습니다.
사용자가 null이나 undefined를 전달하면 None을 반환하고 그렇지 않으면 Some을 반환합니다.
function Option<T>(value: null | undefined): None // ①
function Option<T>(value: T): Some<T> // ②
function Option<T>(value: T): Option<T>{ // ③
if(value == null){
return new None
}
return new Some(value)
}
- 사용자가 Option에 null이나 undefined를 전달하면 None을 반환합니다.
- 그렇지 않으면 Some<T>를 반환합니다. T는 사용자가 전달한 값의 타입입니다.
- 마지막으로 오버로드된 두 시그니처의 상위 경계를 직접 계산합니다. null | undefined와 T의 상위 경계는 T | null | undefined이고, 간소화하면 T가 됩니다. None과 Some<T>의 상위 경계는 None | Some<T>이므로 이미 정의한 Option<T>로 표현할 수 있습니다.
드디어 끝났습니다!
null일 수도 있는 값에도 안심하고 연산을 수행할 수 있는 간소한 Option 타입이 만들어졌습니다.
'👶 TypeScript' 카테고리의 다른 글
async와 await (0) | 2023.01.23 |
---|---|
비동기 프로그래밍, 동시성과 병렬성 (0) | 2023.01.23 |
에러 처리 - 예외 반환 (0) | 2023.01.23 |
에러 처리 - 예외 던지기 (0) | 2023.01.23 |
에러 처리 - null 반환 (0) | 2023.01.22 |