타입 추론(Type Inference)

타입스크립트는 여러 값들의 타입을 자동으로 추론하는 타입 추론기능이 있습니다. 타입스크립트가 코드를 해석하여 해당 값의 타입을 알아서 찾는 편리한 기능입니다.

let a = 3
let b = "타입"

변수 a와 b는 타입을 명시적으로 설정해주지 않았음에도 불구하고 타입스크립트는 a가 number, b가 string이라는 것을 알 수 있습니다. 타입 추론은 변수를 선언하고 초기화할 때 자동으로 일어나는 과정입니다. 변수 뿐만아니라 함수의 반환값이나 객체의 속성에서도 자동으로 타입을 추론하여 해당 값이 어떤 타입인지 알고 있습니다.

Best common type

let x = [0, 1, null];

위와 같이 배열에 숫자와 null이 동시에 들어간 경우는 어떻게 될까요? 타입스크립트는 여러 표현식으로부터 타입 추론을 할 때, 그 표현식들의 타입들을 사용하여 "최적 공통 타입"을 계산합니다. 위 예시에서 x의 타입을 추론하기 위해, 각 배열 요소의 타입을 고려해야 합니다. 여기서 배열의 타입에 대해 number와 null이라는 두 가지 선택지가 제공됩니다. 타입스크립트의 타입 추론 알고리즘은 타입이 될 수 있는 후보를 모두 고려하여 가장 좁은 범위를 가지는 타입을 가장 적절한 타입으로 취급합니다. 따라서 x는 다음 이미지와 같이 (number | null)[] 타입을 가지게 됩니다.

타입을 가장 좁은 범위로 추론하기 때문에 대게는 현재 사용되는 타입만을 취급하게 됩니다. 대부분의 경우에는 올바르나 그렇지 못한 경우도 있습니다.

let zoo = [new Rhino(), new Elephant(), new Snake()]; // (Rhino | Elephant | Snake)[]

위의 코드에서 zoo는 (Rhino | Elephant | Snake)[]라는 타입을 가지게 됩니다. 하지만 Rhino, Elephant, SnakeAnimal의 하위 클래스라면 zoo의 타입은 Animal[]이 되어야 알맞을 수 있습니다. 이럴때는 명시적으로 타입을 정해주어야 합니다.

let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];

문맥상의 타이핑(Contextual Typing)

타입 추론은 때로 타입스크립에서 "반대 방향"으로도 작동합니다. 이를 "문맥상의 타이핑(contextual typing)"이라고 합니다. 문맥적 타이핑은 표현식의 타입이 그 위치에 의해 암시적으로 결정될 때 발생합니다.

document.getElementById("myButton").onclick = function(event) {
    console.log(event.target);  //<- OK
    console.log(event.animal); //<- Error!
};

event.target은 잘 작동하지만 event.animal은 에러가 발생합니다. 일반적으로 HTML 요소의 onclick 이벤트 핸들러는 MouseEvent를 기대합니다. 따라서 event 매개변수는 일반적으로 MouseEvent 타입으로 추론됩니다. event.targetMouseEvent가 가진 속성으로 에러가 없는 반면 event.animalMouseEvent에는 정의되지 않은 속성이므로 타입스크립트에서는 이를 에러로 처리합니다.document.getElementById("myButton").onclick에 할당된 함수의 매개변수 event가 어떤 타입인지 결정하는 기준은 onclick 이벤트 핸들러가 됩니다.

하지만 다음과 같이 어떤 이벤트인지 알 수 없는 경우에는 문맥상의 타이핑이 작동할 수 없습니다. 따라서 eventany타입이 되어 타입스크립트가 개입할 수 없습니다.

const handler = function(event) {
  console.log(event.animal); //<- OK
}

Narrowing

Narrowing은 타입스크립트에서 특정 조건을 만족할 때 변수의 타입을 더 구체적인 타입으로 추론하는 과정을 말합니다. 주로 조건문(if 문이나 switch 문 등)을 사용하여 변수의 가능한 값(타입)의 범위를 좁히거나, 타입 가드를 활용하여 변수의 타입을 명시적으로 제한하는 방식으로 이루어집니다. Narrowing은 타입스크립트가 코드를 실행하기 전에 타입을 검사하고, 실행 경로에 따라 변수의 타입을 추론 하고 더 정확한 범위로 좁히는 데 사용됩니다.

let x: number | string;

x = 10;
console.log(typeof x); // "number"

if (typeof x === "number") {
    console.log(x.toFixed(2)); // 여기서 x는 number 타입으로 좁혀짐
} else {
    console.log(x.toUpperCase()); // 여기서 x는 string 타입으로 좁혀짐

위 코드에서 x는 초기에 number | string 타입으로 선언되지만 typeof x === "number"와 같은 조건문을 사용하여 x의 타입을 좁혀나가는 과정을 볼 수 있습니다. typeof x === "number" 조건에서는 xnumber 타입으로 좁혀지므로 toFixed 메서드를 사용할 수 있습니다. 반대로 else 블록에서는 xstring 타입으로 좁혀지며, toUpperCase 메서드를 사용할 수 있습니다.

타입 가드(Type Guards)

타입 가드는 특정 조건을 만족할 때 변수의 타입을 좁히는 기능을 제공합니다. 위 예제에서 typeof x === "number"typeof 연산자를 사용하여 타입 가드를 구현한 것입니다. 이 외에도 사용자 정의 함수, instanceof 연산자, in 연산자 등도 타입 가드로 사용될 수 있습니다.

// 사용자 정의 타입 가드 예시
function isFish(pet: Fish | Bird): pet is Fish {
    return (pet as Fish).swim !== undefined;
}

// instanceof를 사용한 타입 가드 예시
if (someValue instanceof MyType) {
    // someValue는 MyType 타입으로 좁혀짐
}

// in 연산자를 사용한 타입 가드 예시
if ("swim" in pet) {
    // pet은 Fish 타입으로 좁혀짐
}

타입 어설션 (Type Assertion)

가끔씩은 타입스크립트에게 타입을 명확하게 알려주어야 할 상황이 생기기도 합니다. 때때로 변수의 타입을 명확하게 알기 어려운 경우나 변수가 여러 타입을 가질 수 있는 경우 등입니다. 이럴 때 타입 어설션을 사용하여 개발자가 직접 변수의 타입을 지정할 수 있습니다.

async function fetchData() {
  try {
    const response = await fetch("https://api.example.com/data");

    if (!response.ok) {
      throw new Error("Failed to fetch data");
    }

    const data: MyData = await response.json();

    // 이후 data 객체를 안전하게 사용
    return data;
  } catch (error) {
    console.error("Error fetching data:", error);
  }
}

위와 같은 함수에서 response.json()은 any를 반환하는 함수입니다. 따라서 해당 함수의 반환값을 MyData로 단정(assertion)해주어야 합니다. const data: MyData = await response.json();와 같이 사용할 수도 있으며 const data = (await response.json()) as MyData;와 같이 사용하여도 됩니다. 타입 어설션은 외부 라이브러리 사용이나 HTTP 요청 등 타입스크립트가 타입을 확실하게 알기 어려운 상황에서 주로 사용합니다.

null assertion

때로는 변수가 null 또는 undefined일 가능성이 있는 경우, non-null 연산자 !를 사용하여 null이 아님을 명시적으로 지정할 수 있습니다.

let maybeNullValue: string | null = null;

// maybeNullValue가 null일 가능성이 있는데도 !를 사용하여 타입을 명시적으로 지정
const definiteValue: string = maybeNullValue!;

프로그램의 구조 상 개발자는 해당 값이 절대 null이 될 수 없음을 알고 있지만 타입스크립트는 이를 알 수 없는 경우가 많습니다. 이때 non-nill 연산자를 활용합니다.

주의 사항

타입 가드 없이 as! 연산자를 남발하는 것은 코드의 타입 안정성을 저하시킬 수 있습니다. 가능하면 타입 가드를 사용하여 실행 시점의 조건을 통해 변수의 타입을 좁히는 방식으로 타입스크립트의 강력한 타입 추론 기능을 활용하는 것이 좋습니다. as! 연산자는 타입스크립트가 제공하는 타입 시스템을 보완하는 도구로, 필요한 경우에만 명시적으로 사용하는 것이 좋습니다.

더 알아보기: Array.prototype.pop의 반환값에는 항상 undefined가 포함된다?!

가끔은 타입가드를 통해 타입을 좁혀주었다고 생각하여도 그렇지 못한 경우도 있습니다.

const array: number[] = [1, 2, 3];
let num: number;

if (array.length !== 0) {
  num = array.pop(); // error
}

Array.prototype.pop 메서드는 배열이 비어있다면 undefined를 반환하기 때문에 기본적으로 배열 내부의 요소 | undefined로 타이핑됩니다. 하지만 위의 코드에서 array.length !== 0를 통해 배열이 비어있지 않는 것을 확실하게 하였지만, pop이 반환하는 값은 여전히 배열 내부의 요소 | undefined로 에러가 발생합니다. 이는 TypeScript가 올바르게 추적하기 힘든 문제로 인해 두고 있다고 하며, 편하게는 !를 통한 타입 어설션을 통해 에러를 없애는 방법(권장되지는 않음, 확실히 undefined가 아님을 보장할 수 있는 경우에 사용)을 사용하여야 합니다. 권장되는 방법은 다음과 같습니다.

if (array.length !== 0) {
  const a = array.pop();
  if (a !== undefined) {
    num = a; // ok!
  } else {
      // ...
  }
}

해결법은 미리 알아보았으니 이 문제가 발생하는 원인도 알아보겠습니다. 어떤 경우에 배열의 길이가 0이 아님에도 undefined가 반환될 수 있을까요? 정답은 "없습니다."입니다. pop메서드는 배열의 길이가 0일 때만 undefined를 반환합니다. 다만 앞서 말했듯이 타입스크립트가 이를 올바르게 추적할 수 없기 때문에 발생한 문제입니다. 타입스크립트에서 pop 메서드를 사용할 때마다 해당 배열의 길이가 정확히 얼마가 되는 지 확인할 길이 없습니다.

import axios from "axios";

async function fetchData(): Promise<void> {
  try {
    const response = await axios.get("https://api.example.com/data");

    const data = response.data as number[];
    용;
    console.log(data);
  } catch (error) {
    console.error("Error fetching data:", error);
  }
}

위의 코드처럼 HTTP 요청이 숫자로 이루어진 배열입니다. 그러나 이 배열의 길이를 확인할 방법은 없습니다. 타입스크립트 뿐만 아니라 개발자조차도말입니다. 그래서 타입스크립트의 개발자들은 pop 메서드는 배열의 길이와 상관없이 undefined도 함께 반환하는 것이 더 올바르다고 생각한 것 같습니다. 추가적으로 이런 사례를 지원하기 위한 알고리즘의 변경의 비용이 엄청 클 것입니다. 자세한 내용은 참고 자료에 추가한 링크에서 확인해 볼 수 있습니다.

참고 자료

TypScript Docs, Type Inference
TypeScript GitHub, Issues #30406
stackoverflow

+ Recent posts