타입 가드(Type Guards)

Typescript에서 유니언 타입을 사용할 경우 특정 타입에 따라 사용할 수 있는 메서드, 속성이 다르기 때문에 오류가 발생할 확률이 높음. 그렇기 때문에 해당 값의 타입을 사전에 검사해줘야 하는데, 타입 검사를 통해 오류가 발생할 수 있는 코드 실행을 막아준다는 의미로 이것을 타입 가드(Type Guards)라고 함.


1. in, typeof, instanceof

보통 if, switch 문에서 in, typeof, instanceof 등의 연산자를 사용하는 방식으로 타입을 검사할 수 있는데, null의 type은 object이기 때문에 Javascript 내장 기능만으로는 한계가 있음.

① in: 해당 객체에 특정 속성이 존재하는지 확인 #

"prop1" in obj   // 객체 obj에 prop1 속성이 존재하는가?

② instanceof: 해당 객체가 특정 Class의 인스턴스인지 확인(타입, 인터페이스에는 사용할 수 없음) #

e.target instanceof HTMLElement   // 이벤트 타겟이 HTMLElement의 인스턴스인가?
error instanceof Error  // error가 객체 Error의 인스턴스인가?

 

A. 예시1: catch 문에서의 error

throw new Error('에러');
throw new Error(0);
throw new Error({ status: 404 });

catch문에 넘길 수 있는 에러는 어떠한 타입도 가능하기 때문에 catch의 매개변수인 e는 unknown 타입이고, 내가 임의로 타입을 지정할 수도 없음.

try {
   /* ... */
} catch (error) {
   console.log(error.message);  // ❌ 개체가 '알 수 없는' 형식입니다.
}

따라서, 일반적인 Error 객체의 message 속성에 접근하려 하면 위와 같은 타입 오류가 발생함. message 속성은 Error 객체에만 존재하므로 error가 Error의 인스턴스인지 확인하는 타입 가드를 설정하면 위 오류를 없앨 수 있음.

try {
   /* ... */
} catch (error) {
   if (error instanceof Error) {
      console.log(error.message);  // ✅
   }
      console.log(error);
}



2. Type Predicate

Type Predicate는 해당 값이 특정한 타입인지 여부를 반환하는 특별한 함수로, 사용자 정의 타입 가드라고 할 수 있음. Typescript 컴파일러가 해당 타입임을 간주할 수 있도록 정보를 제공해주는 역할을 함.

function isString(value): value is string {
   return typeof value === 'string';
}

function isNumber(value): value is number {
   return typeof value === 'number';
}

Type Predicate는 타입을 검사한 후 boolean을 반환하며, value is string 를 붙여 Typescript 컴파일러가 string 타입임을 단언할 수 있도록 함.

// Type Predicate 사용 예
const converter(v: string | number) {
   if (isString(v)) {
      return v.match(/a/g);
   } else if (isNumber(v)) {
      return v.toExponential();
   }
}



value is string 을 넣지 않는다면 어떻게 될까?
우리가 코드를 짜고 쭉 읽어내려가면 isString(), isNumber()가 해당 값이 string인지, number인지 여부를 반환한다는 것을 알기 때문에 converter() 함수에서 문제가 생길 일이 없다는 것을 단언할 수 있음.

// isString(value)에 'value is string'을 넣지 않았을 때
const converter(v: string | number) {
   if (isString(v)) {
      return v.match(/a/g);  // ❌ number' 형식에 'match' 속성이 없습니다
   } else if (isNumber(v)) {
      return v.toExponential();  // ❌ 'string' 형식에 'toExponential' 속성이 없습니다
   }
}

우리가 이 함수에 오류가 없다는 것을 단언할 수 있는 것은 직접 코드를 실행(시뮬레이션)해봤기 때문. 즉, Runtime에서의 결과를 보고 판단한 것. 하지만, Typescript는 컴파일 단계(Compile Time)에서만 타입 검사를 하기 때문에 isString()이 어떠한 값을 반환하는지 알 수 없음. 따라서, 타입 좁히기(Type Narrowing)이 제대로 작동하지 않음.

Type Predicate 함수의 내용을 바로 if 문에 넣으면 컴파일 단계에서 타입 좁히기(Type Narrowing)이 가능해지기 때문에 위와 같은 컴파일 에러는 사라짐.

// Typescript 오류가 발생하지 않음
const converter(v: string | number) {
   if (typeof v === 'string') {
      return v.match(/a/g);  
   } else if (typeof v === 'number') {
      return v.toExponential();  
   }
}

 

A. string[] 타입인지 확인하는 함수

interface ServerTag {
   tagName: string;
}

type TagArr = string[] | ServerTag[];

위와 같이 TagArr 타입에 ① 문자열로만 이루어진 배열, ② 특정한 객체로 이루어진 배열 두 가지 타입이 존재할 때, string[] 타입인지 확인하기 위해서는 Type Predicate를 사용하면 쉽게 타입 좁히기를 할 수 있음.

// value의 모든 원소가 string인지 확인하는 함수
function isArrayOfString(value: unknown): value is string[] {
   return Array.isArray(value) && value.every(item => typeof item === "string");
}
// Type Predicate 사용하기
function tagFormat(tagArr: TagArr) {
   let formatted: TagArr = [];

   if (isArrayOfString(tagArr)) {
      /* string[] 타입일 때, 처리할 작업 */
   } else {
      /* ServerTag[] 타입일 때, 처리할 작업 */
   }

   return formatted;
}

 

 

B. 특정 변수가 객체의 키에 해당하는지 확인하는 함수

const ERROR_MESSAGE = {
   Unauthorized: "아이디와 비밀번호를 다시 확인해주세요.",
   Forbidden: "유효하지 않은 접근입니다."
}

function MapErrorMessage(err: Error) {
   const { message } = err;
   
   if (message in ERROR_MESSAGE) {
      // ❌ 'string' 형식의 매개 변수가 포함된 인덱스 시그니처를 찾을 수 없습니다
      return ERROR_MESSAGE[message];
   }
}

위와 같이 변수 message가 ERROR_MESSAGE의 키에 해당하도록 타입 가드를 해줬음에도 if 문 안에서 타입 좁히기가 제대로 작동하지 않음. Typescript에서 in 연산자를 사용할 때, "문자열" in obj 는 제대로 작동하지만 변수 in obj 는 컴파일러 성능 문제로 인해 작동하지 않는다고 함. 따라서, Type Predicate를 이용해 변수 message가 ERROR_MESSAGE의 키인지 확인하는 작업이 필요함.

function isKeyOf<T extends object>(s: PropertyKey, obj: T): s is keyof T {
   try {
      return s in obj;
   }
   catch (e) {
      return false;
   }
}

한편, in 연산자는 object가 아닌 객체(숫자, 문자열, null, undefined, symbol)에 연산하면 위와 같이 에러가 발생하므로 isKeyOf()의 두번째 parameter에 객체가 아닌 값을 받지 못하도록 할 필요가 있음.

function MapErrorMessage(err: Error) {
   const { message } = err;
   
   if (isKeyOf(message, ERROR_MESSAGE)) {
      return ERROR_MESSAGE[message];
   }
}

변수 key가 객체 obj의 키에 해당한다면 함수는 true를 반환해 "s is keyof obj" 가 되므로 MapErrorMessage()의 if 문 안에서 변수 message는 ERROR_MESSAGE의 키가 됨.

 

한편, PropertyKey 타입은 객체의 키로 올 수 있는 string | number | symbol 의 유니언 타입.


 

3. 주의할 점

A. 제네릭 함수와 타입 가드 #

type Return<T> = T extends string ? number : T extends number ? string : never;

function typeSwitch<T extends string | number>(x: T): Return<T> {
  if (typeof x == "string") {
    // ❗ Type 'number' is not assignable to type 'Return<T>'.
    return 42;
  } else if (typeof x == "number") {
    // ❗ Type 'string' is not assignable to type 'Return<T>'.
    return "Hello World!";
  }
  throw new Error("Invalid input")
}

 

위와 같이 string | number 제약 조건의 타입 인수 T를 가지는 제네릭 함수를 생각해보자. if 문에서 x의 타입을 좁혔기 때문에 Return<T>에 의해 x가 string이면 반환값은 number, number이면 반환값이 string이 된다는 건 자명함. 하지만 실제로는 위와 같이 반환값에 대한 타입 오류가 발생함.

 

타입 가드는 변수 x의 타입을 좁히지만, 타입 인자 T는 좁히지 않기 때문. 타입 가드에 의해 x의 타입이 string으로 좁혀졌다고 하더라도 x가 string 타입인 것이지 그것의 타입인 T까지 string이라고는 할 수 없음. 또한, 타입 인자의 타입까지 좁히기에는 그 이점에 비해 타입 시스템의 복잡도가 커지기 때문에 이점이 없다고 함.

const x = '' as string | number

typeSwitch(x)

예를 들어, 위와 같이 x의 타입을 string | number로 캐스팅했을 때, if 문에서 타입 가드로 x가 string 타입으로 좁혀졌어도 타입 인자 T는 여전히 string | number. 이러한 상황에서 발생하는 타입 오류를 제거하기 위해서는 as 키워드를 사용해 반환값의 타입을 단언해주는 수밖에 없음.

function typeSwitch<T extends string | number>(x: T): Return<T> {
  if (typeof x == "string") {
    return 42 as Return<T>;
  } else if (typeof x == "number") {
    return "Hello World!" as Return<T>;
  }
  throw new Error("Invalid input")
}

 

 

 

 

 

 


[참고]
How do you use typed errors in async catch()
Test for array of string type in TypeScript
[typescript] readonly 배열의 includes 인수 범위를 확장하기 (Type Predicates)
Aha! Understanding Typescript’s Type Predicates

Typescript: type narrowing not working for `in` when key is stored in a variable

Generic type extending union is not narrowed by type guard

 

 

'TypeScript' 카테고리의 다른 글

Distributive Conditional Types  (0) 2024.04.22
함수 오버로딩(Overloading)  (0) 2022.09.24
Array 메서드와 never[]  (0) 2022.07.08
Typescript의 다양한 타입들  (0) 2022.07.08
제네릭 타입(Generics)  (0) 2022.07.06