본 글은 Kent C. Dodds의 Get a catch block error message with TypeScript 글의 한글 번역본입니다.

Get a catch block error message with TypeScript

본문

아래 코드를 한 번 보자.

const reportError = ({ message }) => {
  // 로깅서비스로 에러 전송
};

try {
  throw new Error("Oh no!");
} catch (error) {
  // 진행은 하겠지만, 리포트는 전송하자.
  reportError({ message: error.message });
}

여태까지는 좋아보인다. 하지만 그건 어디까지나 JavaScript를 이용하고 있기 때문이다. 여기에 TypeScript를 더해보자:

const reportError = ({ message }: { message: string }) => {
  // 로깅서비스로 에러 전송
};

try {
  throw new Error("Oh no!");
} catch (error) {
  // 진행은 하겠지만, 리포트는 전송하자.
  reportError({ message: error.message });
}

reportError 호출에서 에러가 나는 것을 발견할 수 있다. error.message 부분에서 에러가 난다. TypeScript는 error의 타입을 unknown을 기본값으로 갖기 때문이다. 그리고 실제로 그게 맞기도 하다! 에러의 세계에서 발생한 에러의 타입에 대해서는 전할 수 있는 확신이 부족하다. 실제로 이는 Promise의 rejection항목 .catch(error => {})에 promise generic (Promise<ResolvedValue, NopeYouCantProvideARejectedValueType>)을 이용하여 타입을 제공하지 못하는 이유와 같다고 할 수 있다. 실제로 던져진 값은 에러가 아닐 수도 있다. 아래와 같은 어떤 값도 가능하다.

throw "What the!?";
throw 7;
throw { wut: "is this" };
throw null;
throw new Promise(() => {});
throw undefined;

진짜 아무 타입이나 던질 수 있다. 간단해 보이지 않는가? 그러면 에러에 타입 어노테이션을 추가해서 에러만 던져질 것이라 명시해주자.

try {
  throw new Error("Oh no!");
} catch (error: Error) {
  // 진행은 하겠지만, 리포트는 전송하자.
  reportError({ message: error.message });
}

잠깐! 이렇게 작성하면 아래와 같은 TypeScript 컴파일 에러가 발생한다:

Catch clause variable type annotation must be 'any' or 'unknown' if specified. ts(1196)

이런 에러가 발생하는 이유는 우리가 작성한 코드가 분명 에러 이외의 다른 것을 던져줄 수 없도록 보여지지만, JavaScript의 희안한 동작 방식으로 인하여 이상한 방식으로 에러 객체를 몽키패칭한 써드 파티 라이브러리가 완전히 다른 무언가를 던지도록 할 수도 있다:

Error = function () {
  throw "Flowers";
} as any;

그럼 이 문제를 어떻게 해결할 수 있을까?

try {
  throw new Error("Oh no!");
} catch (error) {
  let message = "Unknown Error";
  if (error instanceof Error) message = error.message;
  // 진행은 하겠지만, 리포트는 전송하자.
  reportError({ message });
}

이렇게 하니까 이제 더 이상 TypeScript가 에러를 발생시키지도 않고, 우리는 이제 완벽히 기대하지 못했던 일이 발생해도 핸들링 할 수 있게 되었다. 여기서 조금 더 잘 해보자:

try {
  throw new Error("Oh no!");
} catch (error) {
  let message;
  if (error instanceof Error) message = error.message;
  else message = String(error);
  // 진행은 하겠지만, 리포트는 전송하자.
  reportError({ message });
}

여기서 만약 error가 실제로 Error 객체가 아니라면 에러를 stringify 하여 뭔가 유용한게 나오길 기대해보면 된다.

이렇게 만든 코드를 모든 catch 블록에서 사용할 수 있는 유틸 함수로 만들어줄 수도 있다.

function getErrorMessage(error: unknown) {
  if (error instanceof Error) return error.message;
  return String(error);
}

const reportError = ({ message }: { message: string }) => {
  // 로깅서비스로 에러 전송
};

try {
  throw new Error("Oh no!");
} catch (error) {
  // 진행은 하겠지만, 리포트는 전송하자.
  reportError({ message: getErrorMessage(error) });
}

유용하게 사용해주길 바란다.

Update: Nicolas와 Jesse가 추천해준 방법을 조합하여 아래와 같은 결과를 낼 수도 있다.

type ErrorWithMessage = {
  message: string;
};

function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
  return (
    typeof error === "object" &&
    error !== null &&
    "message" in error &&
    typeof (error as Record<string, unknown>).message === "string"
  );
}

function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
  if (isErrorWithMessage(maybeError)) return maybeError;

  try {
    return new Error(JSON.stringify(maybeError));
  } catch {
    // 순환 참조와 같이 maybeError를 stringify하는 과정에서 발생하는
    // 에러에 대해 fallback을 제공한다
    return new Error(String(maybeError));
  }
}

function getErrorMessage(error: unknown) {
  return toErrorWithMessage(error).message;
}

역자 해설

function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
  return (
    typeof error === "object" &&
    error !== null &&
    "message" in error &&
    typeof (error as Record<string, unknown>).message === "string"
  );
}

위에 있는 error is ErrorWithMessage는 무얼 뜻하는걸까? 위 예제에서 사용된 isErrorWithMessage 자체의 기본적인 타입은 boolean이라 할 수 있다. 그렇다면 여기서 사용된 is 키워드는 Type Predicate, 직역하자면 ‘타입 단정’이라 할 수 있다.

TypeScript에서 다양한 타입에 대해 공부를 하다 보면 타입을 더 정교하게 접근하는 것에 학습을 하는데, 타입 단정은 여기서 한 발 더 나아가 코드 내에서 특정 타입이 어떻게 바뀌는지를 좀 더 컨트롤 할 수 있게 해준다.

예를들어 위 예제에서 maybeErrorunknown 타입을 갖는다. 하지만 isErrorMessage를 통과하고 나면, type은 message 프로퍼티를 갖고 있는 객체 타입이란 것이 확인이 되기 때문에 더 이상 unknown 타입이 아니게 된다.

여기서 error is ErrorWithMessage를 전해줌으로써, 앞으로 해당 함수가 true가 되면, 이후에 사용될 error 객체는 ErrorWithMessage 타입을 갖게 된다는 점을 의미한다.

여기서 주의해야할 점은, error is ErrorWithMessage가 적용되는 지점은, error 자체가 해당 타입으로 확인 될 때만 사용할 수 있다는 점이다.

function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
  if (isErrorWithMessage(maybeError)) return maybeError;

  // ...
}

예를들어, 위 코드에서 isErrorWithMessage(maybeError)true일 경우, maybeError의 타입은 ErrorWithMessage가 되지만, 그 이외의 경우에는 모두 unknown으로 남게 된다.