ghlee.dev

my profile picture

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에서도 활용할 수 있을지 살펴보겠다.

undefinednull을 대신하는 Option<T> 타입도 있다.

미리 말하자면, 이 에러 처리 방식이 Rust에만 있는 것은 아니다. 함수형 프로그래밍 패러다임을 따르는 언어에서는 흔한 방법으로 알려져 있다. 다만, 나는 함수형 프로그래밍을 깊이 이해하고 설명할 정도의 지식이 있는 것은 아니다. 단순히 Result<T, E> 타입이 실용적이라고 느껴 차용했을 뿐임을 밝힌다.

그렇다면 TypeScript에는 어떤 문제가 있는지부터 알아보자.

try...catchthrow

우선 try...catch의 문법을 보자. 사용 방법은 간단하다.

TypeScript
try {
  Something();
} catch (error) {
  // TODO: 예외 처리
}

Something() 함수를 try로 시작하는 블록에서 호출하였다. 실행 중 예외가 발생하면 제어 흐름이 catch 블록으로 넘어간다. catch 블록에서는 예외에 대한 값을 받아올 수 있다. 이때 catch 블록에서 받아오는 값은 반드시 에러 객체일 필요가 없다.

TypeScript
try {
  throw 1;
} catch (num) {
  console.log(num); // 1
}

이처럼 다소 의외의 사용법도 가능하다. 😬

throw가 모든 타입의 값을 던질 수 있기 때문에, 예상치 못한 코드 패턴이 발생할 수 있다. 예를 들어, 숫자 1을 던지는 것이 에러 상황을 의미한다고 볼 수 있을까?

그러나 이는 상대적으로 가벼운 문제다. 내가 실무에서 겪은 try...catch의 문제는 크게 두 가지인데, 그중 하나는 아래와 같다.

TypeScript
try {
  Something0(); // Try expression
  Something1(); // Try expression
  Something2(); // Try expression
  Something3(); // Try expression
} catch (error) {
  // TODO: 예외 처리
}

표현식(Expression)이란 값(Value)으로 평가(Evaluation)될 수 있는 문법 방식을 의미한다.

try...catch 블록 안에는 Something과 같은 함수 표현식을 여러 개 사용할 수 있다. 이 때문에 catch 블록에서 에러를 받더라도, 정확히 어떤 Something 함수에서 문제가 발생했는지 알기 어렵다. 결과적으로 디버깅이 어려워진다.

또 다른 문제는 다음과 같다.

TypeScript
function throwError() {
  throw new Error(); // 반환 타입은 Error일까? 그렇지 않다.
}

TypeScript에서 에러를 던지는 throwError 함수를 정의한다고 가정해보자. 이 함수의 반환 타입을 추론하면 Error 타입일 것 같지만, 실제로는 void로 처리된다. 이는 try...catch 블록 안에서도 동일하다.

TypeScript
// 함수 시그니처
function ThrowError(): void;

에러를 던지는 함수지만, 반환 타입이 void로 추론된다.

그러나 throw 대신 return을 사용하면, 의도한 대로 반환 타입을 추론할 수 있다.

TypeScript
function ThrowError() {
  return new Error();
}
TypeScript
// 함수 시그니처
function ThrowError(): Error;

그러나 return을 사용하면 catch 블록에서 에러를 직접 받아올 수 없다. 결국 try...catch 문법과 함께 throw를 사용해야 하지만, 이 경우 함수 시그니처만으로 반환 타입을 정확히 알 수 없으므로 함수 내부 구현을 확인해야 한다.

이상적인 함수라면 함수 시그니처만으로도 동작을 유추할 수 있어야 하지만, throw는 이를 어렵게 만든다.

throwvoid 타입 관련한 문제는 TypeScript의 해당 이슈에서 자세히 확인할 수 있다. TypeScript는 JavaScript의 기본 동작을 해치지 않는 것을 원칙으로 하기 때문에, 이 문제를 해결하기 어려워 보인다. 결국, 이 문제는 JavaScript 언어 스펙에서 해결되지 않는 한, 프로그래머가 직접 해결해야 한다.

Result<T, E>

글 제목에 있는 Result<T, E>의 정체는 Rust에서 지원하는 타입으로, 앞서 서론에서 언급한 개념이다. 제네릭(Generic) T는 정상적인 결괏값에 대한 타입을, E는 에러 발생 시 반환할 값의 타입을 의미한다. 덕분에 함수 시그니처만 보고도 에러 발생 가능성을 즉시 파악할 수 있다.

Rust 코드로 간단한 예제를 한번 보자.

Rust
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("0으로 나눌 수 없습니다."))
    } else {
        Ok(a / b)
    }
}

Rust가 익숙하지 않다면 생소할 수 있지만, 단순히 두 개의 부동소수점을 받아 나눗셈하는 함수다. 분모가 0이면 에러 메시지를 반환하고, 그렇지 않으면 나눗셈 결과를 반환한다. 이때 OkErr을 사용해 값을 한 번 래핑(Wrapping)하여 반환한다. 따라서 Result는 단순한 타입이 아니라 OkErr으로 구성된 열거형(Enum)이기도 하다.

아래는 divide 함수를 사용하는 예제이다.

Rust
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 문법을 이용하여 OkErr 케이스를 구분하여 처리한다. Rust에서는 if let 문법을 사용할 수도 있으며, unwrap, unwrap_or_else 등 다양한 처리 방법을 제공한다.

언어마다 문법은 다르지만, Rust의 구체적인 문법을 몰라도 핵심 개념을 이해하는 데는 문제가 없다. 중요한 점은 에러가 발생할 가능성이 있는 함수는 반드시 정상적인 결괏값 또는 에러 값을 반환하도록 강제함으로써, 에러 처리를 명확하게 하고 런타임에서 발생할 수 있는 취약점을 줄이는 것이다.

반면, JavaScript의 try...catchthrow만으로는 에러를 엄격하게 처리하기 어렵다. 여러 개의 에러를 던지는 함수가 try 블록에 포함되더라도, catch 블록에서 이를 처리하지 않으면 런타임에서 감지할 수 없다. 프로그램이 에러가 발생하지 않은 것처럼 동작하는 문제가 발생할 수도 있다.

또한, throw를 사용하면 반환 타입을 명확히 추론할 수 없기 때문에, 함수 외부에서는 어떤 에러가 발생할지 알기 어렵다. 결국, 함수 내부를 직접 살펴보고 에러가 발생할 가능성이 있는 코드를 찾아야만 한다.

TypeScript에서 Result<T, E>

그렇다면 Result<T, E> 같은 에러 처리 방법을 TypeScript에서는 어떻게 구현할 수 있을까?

처음에는 직접 Result<T, E> 타입을 구현해 볼까 생각했다. 몇 가지 인터페이스를 정의하고 코딩 스타일을 정리하면 직접 구현하는 것도 어렵지 않을 것 같았다. 그러나 구현하기 전에 검색해 보니, 이미 여러 오픈 소스 라이브러리가 잘 만들어져 있었다.

그래서 아래 네 가지 오픈 소스 라이브러리를 간략히 소개하려고 한다.

neverthrow

throw를 쓰지 말라는 강렬한 이름을 가진 이 라이브러리는 Rust의 Result<T, E>와 상당히 유사하다. 설명하는 것보다 직접 코드 예제를 살펴보는 게 더 빠를 것이다.

아래는 Rust로 작성했던 divide 함수를 neverthrow 라이브러리를 이용해 재구현한 코드다.

TypeScript
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 함수를 사용하는 예제도 한 번 살펴보자.

TypeScript
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 타입을 반환 타입으로 선언함으로써 함수 시그니처만 보고도 오류가 발생할 수 있는 함수를 미리 확인할 수 있게 되었고, 결과에 바로 접근하는 게 아니라, ifswitch 같은 제어 흐름 문법으로 함수가 성공했는지 여부를 확인한 후 접근할 수 있게 되었다.

아래는 neverthrow 내부 코드의 일부이다.

TypeScript
// 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 타입은 OkErr 클래스를 유니언(Union) 타입으로 가지며 Result 타입을 반환 타입으로 가지고 있는 함수는 호출한 결괏값에서 바로 value에 접근할 수 없다.

왜냐하면 유니언 타입이기 때문에 OkErr타입 중 어느 타입인지 TypeScript가 알지 못하기 때문이다. 그래서 isOk 같은 유틸 함수를 통해서 어느 유니언 타입인지 TypeScript의 타입 검사기가 알 수 있게 해야 한다. 그래서 직전의 예제에서 isOk를 사용하지 않고서는 value에 접근할 수 없고, 타입 에러가 발생했던 것이다.

이를 순서대로 살펴보면 다음과 같다.

[1] 함수를 호출하여 Result 타입을 반환 값으로 받는다.
[2] 현재 TypeScript의 타입 검사기는 반환 값이 OkErr중 어느 것인지 알지 못한다.
[3] isOk 함수와 if 문을 이용하여 둘 중 하나의 타입으로 범위를 좁힌다. 이 방법을 통해 타입 검사기는 둘 중 어느 타입인지 추론할 수 있게 된다.
[4] Result.value 또는 Result.err 값에 접근이 가능해진다.

그리고 isOkisErr 같은 유틸 함수는 IResult라는 인터페이스에 함수 시그니처만 정의되며, 이를 실제 구현하는 건 OkErr 클래스이다. Github에 가보면 isOkisErr외에도 다양한 유틸 함수들이 있으니 참고해 보면 좋을 것이다.

fp-ts

fp-ts는 에러 처리를 위한 라이브러리라기보다는 함수형 프로그래밍 패러다임을 쉽게 구현할 수 있도록 돕는 라이브러리에 가깝다. fp-ts는 Result 타입과 유사한 Either 타입을 제공한다.

TypeScript
type Either<E, A> = Left<E> | Right<A>;

Either 타입은 LeftRight 타입을 유니언 타입으로 가지며, 이를 통해 성공과 실패 케이스를 구분할 수 있다. 사실, Either 타입은 Rust의 Option 타입과 유사하다. (fp-ts에서는 Option 타입을 별도로 제공하기도 한다.) Option 타입은 JavaScript로 비유할 때, 성공과 nullable 케이스를 유니언 타입으로 가지는 것으로 이해하면 쉽다.

Effect

Effect도 함수형 프로그래밍 패러다임을 구현하는 라이브러리에 가깝다. Effect에서도 Result 타입과 유사한 Effect 타입을 제공한다.

TypeScript
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 타입에 유사하다고 볼 수 있다.

TypeScript
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이라고 하는 간단한 클래스를 만들어 보았다.

TypeScript
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
}

Mapget 함수는 결괏값이 없으면 undefined를 반환하는데, 이 부분을 Maybe로 감싸고 get 함수를 오버라이딩하여 프로그래머가 undefined를 무시하지 않고 안전하게 처리할 수 있게 강제해 보았다. 실제 오버라이딩된 get 함수의 함수 시그니처는 다음과 같다.

TypeScript
SafeMap<K, V>.get(key: K): Maybe<V>

당연하지만 이외에도 다양한 활용 방법이 있을테니 직접 고민해보면 더 좋을듯 하다.

이로써 총 네 가지의 라이브러리를 간략하게 살펴보았는데 개인적으로는 neverthrow와 true-myth가 ErrorNullable 처리에 집중한 라이브러리여서 마음에 들었다. 물론 fp-ts와 Effect도 훌륭하지만 이 두 라이브러리는 함수형 프로그래밍 전반에 대한 기능을 제공해서 부담스럽게 느껴졌다. 반대로 얘기하면 neverthrow와 true-myth에는 기능이 많지 않으니 풍부한 유틸(Util)을 원한다면 fp-ts와 Effect 쪽에 알맞을 것이다.

ECMAScript Safe Assignment Operator 제안

최근 ECMAScript의 새로운 제안으로 이른바 안전 할당 연산자라고 부를 ?= 라는 새로운 연산자를 Arthur Fiorette 라는 프로그래머가 제안했다. 안전 할당 연산자를 사용하면 [error, response] 처럼 에러와 결괏값을 가진 튜플로 결과를 반환하자는 제안이다. (Go 언어에서 유사한 문법을 사용한다.)

구현 방식은 다르지만 결국 내가 이 글에서 얘기했던 에러 처리를 공고히 하려는 취지와 동일하며 재미있는 제안이라고 생각한다.

TypeScript
const [error, response] ?= await fetch("https://arthur.place")

제안 작성자가 제시한 예시 중 하나이다. 실제로 작동하는 코드가 아니니 오해하지 말자.

제안서에서는 이 안전 할당 연산자를 사용하는 다양한 예시가 제시되고 있으므로, 궁금하다면 아래 링크에서 더 자세히 확인해볼 수 있다.

Link: proposal-safe-assignment-operator

마치며

다른 프로그래밍 언어에서의 에러 처리 방법을 TypeScript로 구현해 보았다. 앞서 살펴본 것처럼 구현 방식에는 차이가 있지만, 결국 중요한 핵심은 에러가 발생할 수 있는 경우를 놓치지 않고 처리하는 것이다. 웹 서비스를 사용할 때, 간혹 Nullable 값이 그대로 노출되는 경우를 접하기도 하는데, 적어도 TypeScript의 컴파일 타임에는 이러한 문제를 방지해야 한다고 생각한다.

실무에서는 옆자리 동료와의 코드 스타일을 맞추어야 하기 때문에, 직접 적용하기 쉽지 않을 수 있다. 그럼에도 불구하고, 만약 사용한다면 neverthrow나 true-myth와 유사한 형태의 코드를 직접 만들어서 사용해 볼 수도 있을 것이다. 나는 글에서 언급하지 않은 map, unwrap 같은 다양한 유틸리티 함수들이 꼭 필요하지 않다고 생각하고, 단순히 제어 흐름을 통해 에러 처리를 명확하게 할 수 있다면 충분하다고 본다.

사실 프론트엔드 영역에서는 특정 컴포넌트(혹은 함수)에서 발생한 예외를 상위 컴포넌트나 호출된 상위 함수에서 처리하는 경우가 많다. 또한, 여러 예외를 하나의 Fallback으로 처리하는 패턴이 자주 사용되는데, 이 때문에 try...catch 문법이 충분히 유용하게 사용될 수 있다.

그럼에도 불구하고, Result<T, E>와 같은 에러 처리 패턴을 병행한다면 런타임에서 발생할 수 있는 취약점을 줄이고, 에러가 발생할 가능성이 있는 부분을 명확히 구분하며 안전한 코드를 작성하는 데 도움이 될 것이다.