ghlee.dev
TypeScript에서의 Result<T, E>
2024. 7. 17.현재 JavaScript에서 지원하는 에러(Error) 처리 방법으로 try...catch
문법이 있다. 이는 일반적으로 Exception(예외 처리)이라고 불리며, Java, C++, Python 등 다양한 언어에서 지원한다.
try와 catch를 붙여쓸지 사소한 고민을 하다가 MDN 문서의 표현 방식(
try...catch
)을 따르기로 했다. 🤭
에러 처리를 if
같은 제어 흐름문으로 하는 방식과 try...catch
를 이용한 예외 처리는 늘 비교 대상이 된다. 이는 개발자들 사이에서 뜨거운 감자 같은 주제다. 개인적으로 이 주제에 충분한 지식과 경험이 없어 논쟁에 참여할 생각은 없다.
최근 Rust에서 Result<T, E>
타입을 접하고 이를 에러 처리에 사용하면서 유용하다고 느꼈다. 그래서 이 글에서는 Result<T, E>
타입을 TypeScript에서도 활용할 수 있을지 살펴보겠다.
undefined
와null
을 대신하는Option<T>
타입도 있다.
미리 말하자면, 이 에러 처리 방식이 Rust에만 있는 것은 아니다. 함수형 프로그래밍 패러다임을 따르는 언어에서는 흔한 방법으로 알려져 있다. 다만, 나는 함수형 프로그래밍을 깊이 이해하고 설명할 정도의 지식이 있는 것은 아니다. 단순히 Result<T, E>
타입이 실용적이라고 느껴 차용했을 뿐임을 밝힌다.
그렇다면 TypeScript에는 어떤 문제가 있는지부터 알아보자.
try...catch
와 throw
우선 try...catch
의 문법을 보자. 사용 방법은 간단하다.
try {
Something();
} catch (error) {
// TODO: 예외 처리
}
Something()
함수를 try
로 시작하는 블록에서 호출하였다. 실행 중 예외가 발생하면 제어 흐름이 catch
블록으로 넘어간다. catch
블록에서는 예외에 대한 값을 받아올 수 있다. 이때 catch
블록에서 받아오는 값은 반드시 에러 객체일 필요가 없다.
try {
throw 1;
} catch (num) {
console.log(num); // 1
}
이처럼 다소 의외의 사용법도 가능하다. 😬
throw
가 모든 타입의 값을 던질 수 있기 때문에, 예상치 못한 코드 패턴이 발생할 수 있다. 예를 들어, 숫자 1
을 던지는 것이 에러 상황을 의미한다고 볼 수 있을까?
그러나 이는 상대적으로 가벼운 문제다. 내가 실무에서 겪은 try...catch
의 문제는 크게 두 가지인데, 그중 하나는 아래와 같다.
try {
Something0(); // Try expression
Something1(); // Try expression
Something2(); // Try expression
Something3(); // Try expression
} catch (error) {
// TODO: 예외 처리
}
표현식(Expression)이란 값(Value)으로 평가(Evaluation)될 수 있는 문법 방식을 의미한다.
try...catch
블록 안에는 Something
과 같은 함수 표현식을 여러 개 사용할 수 있다. 이 때문에 catch 블록에서 에러를 받더라도, 정확히 어떤 Something
함수에서 문제가 발생했는지 알기 어렵다. 결과적으로 디버깅이 어려워진다.
또 다른 문제는 다음과 같다.
function throwError() {
throw new Error(); // 반환 타입은 Error일까? 그렇지 않다.
}
TypeScript에서 에러를 던지는 throwError
함수를 정의한다고 가정해보자. 이 함수의 반환 타입을 추론하면 Error
타입일 것 같지만, 실제로는 void
로 처리된다. 이는 try...catch
블록 안에서도 동일하다.
// 함수 시그니처
function ThrowError(): void;
에러를 던지는 함수지만, 반환 타입이
void
로 추론된다.
그러나 throw
대신 return
을 사용하면, 의도한 대로 반환 타입을 추론할 수 있다.
function ThrowError() {
return new Error();
}
// 함수 시그니처
function ThrowError(): Error;
그러나 return
을 사용하면 catch
블록에서 에러를 직접 받아올 수 없다. 결국 try...catch
문법과 함께 throw
를 사용해야 하지만, 이 경우 함수 시그니처만으로 반환 타입을 정확히 알 수 없으므로 함수 내부 구현을 확인해야 한다.
이상적인 함수라면 함수 시그니처만으로도 동작을 유추할 수 있어야 하지만, throw
는 이를 어렵게 만든다.
throw
와 void
타입 관련한 문제는 TypeScript의 해당 이슈에서 자세히 확인할 수 있다. TypeScript는 JavaScript의 기본 동작을 해치지 않는 것을 원칙으로 하기 때문에, 이 문제를 해결하기 어려워 보인다. 결국, 이 문제는 JavaScript 언어 스펙에서 해결되지 않는 한, 프로그래머가 직접 해결해야 한다.
Result<T, E>
글 제목에 있는 Result<T, E>
의 정체는 Rust에서 지원하는 타입으로, 앞서 서론에서 언급한 개념이다. 제네릭(Generic) T
는 정상적인 결괏값에 대한 타입을, E
는 에러 발생 시 반환할 값의 타입을 의미한다. 덕분에 함수 시그니처만 보고도 에러 발생 가능성을 즉시 파악할 수 있다.
Rust 코드로 간단한 예제를 한번 보자.
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("0으로 나눌 수 없습니다."))
} else {
Ok(a / b)
}
}
Rust가 익숙하지 않다면 생소할 수 있지만, 단순히 두 개의 부동소수점을 받아 나눗셈하는 함수다. 분모가 0이면 에러 메시지를 반환하고, 그렇지 않으면 나눗셈 결과를 반환한다. 이때 Ok
와 Err
을 사용해 값을 한 번 래핑(Wrapping)하여 반환한다. 따라서 Result
는 단순한 타입이 아니라 Ok
와 Err
으로 구성된 열거형(Enum)이기도 하다.
아래는 divide
함수를 사용하는 예제이다.
let result = divide(10.0, 2.0);
match result {
Ok(value) => println!("Result: {:?}", value),
Err(e) => println!("Error: {:?}", e),
}
Rust의
match
문법은 JavaScript의switch
와 유사하다고 생각하면 된다. (엄연히 다르긴 하다.)
divide
함수의 반환 값을 result
변수에 저장한 후 match
문법을 이용하여 Ok
와 Err
케이스를 구분하여 처리한다. Rust에서는 if let
문법을 사용할 수도 있으며, unwrap
, unwrap_or_else
등 다양한 처리 방법을 제공한다.
언어마다 문법은 다르지만, Rust의 구체적인 문법을 몰라도 핵심 개념을 이해하는 데는 문제가 없다. 중요한 점은 에러가 발생할 가능성이 있는 함수는 반드시 정상적인 결괏값 또는 에러 값을 반환하도록 강제함으로써, 에러 처리를 명확하게 하고 런타임에서 발생할 수 있는 취약점을 줄이는 것이다.
반면, JavaScript의 try...catch
와 throw
만으로는 에러를 엄격하게 처리하기 어렵다. 여러 개의 에러를 던지는 함수가 try
블록에 포함되더라도, catch
블록에서 이를 처리하지 않으면 런타임에서 감지할 수 없다. 프로그램이 에러가 발생하지 않은 것처럼 동작하는 문제가 발생할 수도 있다.
또한, throw
를 사용하면 반환 타입을 명확히 추론할 수 없기 때문에, 함수 외부에서는 어떤 에러가 발생할지 알기 어렵다. 결국, 함수 내부를 직접 살펴보고 에러가 발생할 가능성이 있는 코드를 찾아야만 한다.
TypeScript에서 Result<T, E>
그렇다면 Result<T, E>
같은 에러 처리 방법을 TypeScript에서는 어떻게 구현할 수 있을까?
처음에는 직접 Result<T, E>
타입을 구현해 볼까 생각했다. 몇 가지 인터페이스를 정의하고 코딩 스타일을 정리하면 직접 구현하는 것도 어렵지 않을 것 같았다. 그러나 구현하기 전에 검색해 보니, 이미 여러 오픈 소스 라이브러리가 잘 만들어져 있었다.
그래서 아래 네 가지 오픈 소스 라이브러리를 간략히 소개하려고 한다.
neverthrow
throw
를 쓰지 말라는 강렬한 이름을 가진 이 라이브러리는 Rust의 Result<T, E>
와 상당히 유사하다. 설명하는 것보다 직접 코드 예제를 살펴보는 게 더 빠를 것이다.
아래는 Rust로 작성했던 divide
함수를 neverthrow 라이브러리를 이용해 재구현한 코드다.
import { ok, err, Result } from 'neverthrow';
export function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err('0으로 나눌 수 없습니다.');
} else {
return ok(a / b);
}
}
글 작성 시간을 기준으로 neverthrow의 버전은 7.0.0이다.
Rust의 타입과 상당히 유사하게 변했다. neverthrow 라이브러리에서 ok
, err
, Result
만 가져오면 즉시 활용할 수 있다. divide
함수를 사용하는 예제도 한 번 살펴보자.
const result = divide(1, 2); // Result<number, string>
// Property 'value' does not exist on type 'Result<number, string>'.
// Property 'value' does not exist on type 'Err<number, string>'.
console.log(result.value); // ❌ TypeError
if (result.isOk()) {
console.log(result.value); // ⭕️ 'value'에 접근할 수 있다.
}
divide
함수를 사용해 반환값을 받았지만 바로 사용할 수는 없다. Result
인터페이스에 구현된 isOk
메서드를 활용해 함수가 성공했는지 확인한 후, 비로소 실제 결괏값인 value
프로퍼티에 접근할 수 있다. 확인하지 않고 바로 value
프로퍼티에 접근하려 하면 타입 에러가 발생한다.
이 라이브러리는 내가 원하던 조건을 거의 완벽하게 만족한다. Result
타입을 반환 타입으로 선언함으로써 함수 시그니처만 보고도 오류가 발생할 수 있는 함수를 미리 확인할 수 있게 되었고, 결과에 바로 접근하는 게 아니라, if
나 switch
같은 제어 흐름 문법으로 함수가 성공했는지 여부를 확인한 후 접근할 수 있게 되었다.
아래는 neverthrow 내부 코드의 일부이다.
// neverthrow@7.0.0/src/result.ts
export type Result<T, E> = Ok<T, E> | Err<T, E>
export const ok = <T, E = never>(value: T): Ok<T, E> => new Ok(value)
export const err = <T = never, E = unknown>(err: E): Err<T, E> => new Err(err)
interface IResult<T, E> {
isOk(): this is Ok<T, E>
isErr(): this is Err<T, E>
}
export class Ok<T, E> implements IResult<T, E> {
...
}
export class Err<T, E> implements IResult<T, E> {
...
}
코드는 생각보다 간단하다. Result
타입은 Ok
와 Err
클래스를 유니언(Union) 타입으로 가지며 Result
타입을 반환 타입으로 가지고 있는 함수는 호출한 결괏값에서 바로 value
에 접근할 수 없다.
왜냐하면 유니언 타입이기 때문에 Ok
와 Err
타입 중 어느 타입인지 TypeScript가 알지 못하기 때문이다. 그래서 isOk
같은 유틸 함수를 통해서 어느 유니언 타입인지 TypeScript의 타입 검사기가 알 수 있게 해야 한다. 그래서 직전의 예제에서 isOk
를 사용하지 않고서는 value
에 접근할 수 없고, 타입 에러가 발생했던 것이다.
이를 순서대로 살펴보면 다음과 같다.
[1] 함수를 호출하여 Result
타입을 반환 값으로 받는다.
[2] 현재 TypeScript의 타입 검사기는 반환 값이 Ok
와 Err
중 어느 것인지 알지 못한다.
[3] isOk
함수와 if
문을 이용하여 둘 중 하나의 타입으로 범위를 좁힌다. 이 방법을 통해 타입 검사기는 둘 중 어느 타입인지 추론할 수 있게 된다.
[4] Result.value
또는 Result.err
값에 접근이 가능해진다.
그리고 isOk
와 isErr
같은 유틸 함수는 IResult
라는 인터페이스에 함수 시그니처만 정의되며, 이를 실제 구현하는 건 Ok
와 Err
클래스이다. Github에 가보면 isOk
와 isErr
외에도 다양한 유틸 함수들이 있으니 참고해 보면 좋을 것이다.
fp-ts
fp-ts는 에러 처리를 위한 라이브러리라기보다는 함수형 프로그래밍 패러다임을 쉽게 구현할 수 있도록 돕는 라이브러리에 가깝다. fp-ts는 Result
타입과 유사한 Either
타입을 제공한다.
type Either<E, A> = Left<E> | Right<A>;
Either
타입은 Left
와 Right
타입을 유니언 타입으로 가지며, 이를 통해 성공과 실패 케이스를 구분할 수 있다. 사실, Either
타입은 Rust의 Option
타입과 유사하다. (fp-ts에서는 Option
타입을 별도로 제공하기도 한다.) Option
타입은 JavaScript로 비유할 때, 성공과 nullable
케이스를 유니언 타입으로 가지는 것으로 이해하면 쉽다.
Effect
Effect도 함수형 프로그래밍 패러다임을 구현하는 라이브러리에 가깝다. Effect에서도 Result
타입과 유사한 Effect
타입을 제공한다.
import { Effect } from 'effect';
const divide = (a: number, b: number): Effect.Effect<number, Error, never> =>
b === 0
? Effect.fail(new Error('0으로 나눌 수 없습니다.'))
: Effect.succeed(a / b);
이제는 구현 방식에서 유사한 패턴이 보이지 않는가?
true-myth
true-myth는 neverthrow와 유사하게 에러 처리를 위한 라이브러리로 탄생했으며 Result
타입과 Maybe
타입을 지원한다. Maybe
타입은 Rust의 Option
타입에 유사하다고 볼 수 있다.
import Result, { err, ok } from 'true-myth/result';
export function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err('0으로 나눌 수 없습니다.');
} else {
return ok(a / b);
}
}
neverthrow의 예제에서 라이브러리만 true-myth로 바꿔주었는데 어려움없이 적용된다.
이번에는 true-myth에 있는 Maybe
타입을 활용해서 내가 직접 SafeMap
이라고 하는 간단한 클래스를 만들어 보았다.
import Maybe, { just, nothing } from 'true-myth/maybe';
class SafeMap<K, V> extends Map {
private map: Map<K, V>;
constructor() {
super();
this.map = new Map();
}
public get(key: K): Maybe<V> {
const result = this.map.get(key);
if (result) {
return just(result);
} else {
return nothing();
}
}
}
const map = new SafeMap<string, number>();
map.set('test', 0);
const result = map.get('wrong');
// Property 'value' does not exist on type 'Maybe<unknown>'.
// Property 'value' does not exist on type 'Nothing<unknown>'.
console.log(result.value); // ❌ Type Error
if (result.isJust) {
console.log(result.value); // ⭕️ Ok
}
Map
의 get
함수는 결괏값이 없으면 undefined
를 반환하는데, 이 부분을 Maybe
로 감싸고 get
함수를 오버라이딩하여 프로그래머가 undefined
를 무시하지 않고 안전하게 처리할 수 있게 강제해 보았다. 실제 오버라이딩된 get
함수의 함수 시그니처는 다음과 같다.
SafeMap<K, V>.get(key: K): Maybe<V>
당연하지만 이외에도 다양한 활용 방법이 있을테니 직접 고민해보면 더 좋을듯 하다.
이로써 총 네 가지의 라이브러리를 간략하게 살펴보았는데 개인적으로는 neverthrow와 true-myth가 Error
와 Nullable
처리에 집중한 라이브러리여서 마음에 들었다. 물론 fp-ts와 Effect도 훌륭하지만 이 두 라이브러리는 함수형 프로그래밍 전반에 대한 기능을 제공해서 부담스럽게 느껴졌다. 반대로 얘기하면 neverthrow와 true-myth에는 기능이 많지 않으니 풍부한 유틸(Util)을 원한다면 fp-ts와 Effect 쪽에 알맞을 것이다.
ECMAScript Safe Assignment Operator 제안
최근 ECMAScript의 새로운 제안으로 이른바 안전 할당 연산자라고 부를 ?=
라는 새로운 연산자를 Arthur Fiorette 라는 프로그래머가 제안했다. 안전 할당 연산자를 사용하면 [error, response]
처럼 에러와 결괏값을 가진 튜플로 결과를 반환하자는 제안이다. (Go 언어에서 유사한 문법을 사용한다.)
구현 방식은 다르지만 결국 내가 이 글에서 얘기했던 에러 처리를 공고히 하려는 취지와 동일하며 재미있는 제안이라고 생각한다.
const [error, response] ?= await fetch("https://arthur.place")
제안 작성자가 제시한 예시 중 하나이다. 실제로 작동하는 코드가 아니니 오해하지 말자.
제안서에서는 이 안전 할당 연산자를 사용하는 다양한 예시가 제시되고 있으므로, 궁금하다면 아래 링크에서 더 자세히 확인해볼 수 있다.
마치며
다른 프로그래밍 언어에서의 에러 처리 방법을 TypeScript로 구현해 보았다. 앞서 살펴본 것처럼 구현 방식에는 차이가 있지만, 결국 중요한 핵심은 에러가 발생할 수 있는 경우를 놓치지 않고 처리하는 것이다. 웹 서비스를 사용할 때, 간혹 Nullable
값이 그대로 노출되는 경우를 접하기도 하는데, 적어도 TypeScript의 컴파일 타임에는 이러한 문제를 방지해야 한다고 생각한다.
실무에서는 옆자리 동료와의 코드 스타일을 맞추어야 하기 때문에, 직접 적용하기 쉽지 않을 수 있다. 그럼에도 불구하고, 만약 사용한다면 neverthrow나 true-myth와 유사한 형태의 코드를 직접 만들어서 사용해 볼 수도 있을 것이다. 나는 글에서 언급하지 않은 map
, unwrap
같은 다양한 유틸리티 함수들이 꼭 필요하지 않다고 생각하고, 단순히 제어 흐름을 통해 에러 처리를 명확하게 할 수 있다면 충분하다고 본다.
사실 프론트엔드 영역에서는 특정 컴포넌트(혹은 함수)에서 발생한 예외를 상위 컴포넌트나 호출된 상위 함수에서 처리하는 경우가 많다. 또한, 여러 예외를 하나의 Fallback으로 처리하는 패턴이 자주 사용되는데, 이 때문에 try...catch
문법이 충분히 유용하게 사용될 수 있다.
그럼에도 불구하고, Result<T, E>
와 같은 에러 처리 패턴을 병행한다면 런타임에서 발생할 수 있는 취약점을 줄이고, 에러가 발생할 가능성이 있는 부분을 명확히 구분하며 안전한 코드를 작성하는 데 도움이 될 것이다.