7 분 소요

타입 추론

타입스크립트는 타입이 정의되어 있지 않은 변수의 타입을 자동으로 추론한다. 이러한 기능을 ‘타입 추론’이라고 한다.

타입 추론 덕분에 개발자들은 모든 변수에 일일이 타입을 정의하지 않아도 되는 편리함과 유연함을 얻을 수 있다.

하지만 타입스크립트가 모든 상황에 타입을 잘 추론하는 것은 아니다. 예를 들어, 다음과 같이 함수의 매개변수 타입은 자동으로 추론할 수 없다.

function func(param) {
  // 추론 못해잉..
}

이렇게 타입 추론이 불가능한 변수 (ex. 매개변수)에는 암시적으로 any타입이 추론된다. 그러나 타입스크립트의 엄격한 타입 검사 모드(tsconfig.json의 strict 옵션 true 상태)에서는 이런 함시적 any 타입의 추론을 오류로 판단한다. 이를 살펴보기 전에 간단하게 타입 추론의 여러 상황에 대해 알아보자.

타입 추론이 가능한 상황

변수 선언

일반적인 변수 선언부터 복잡한 객체 타입까지 문제없이 잘 추론된다.

let a = 10;
// number 타입으로 추론

let b = "hello";
// string 타입으로 추론

let c = {
  id: 1,
  name: "주노정",
  profile: {
    nickname: "jjunohj",
  },
  urls: ["https://jjunohj.github.io"],
};
// id, name, profile, urls 프로퍼티가 있는 객체 타입으로 추론

구조 분해 할당

객체나 배열을 구조 분해 할당하는 상황에서도 문제없이 잘 추론된다.

let { id, name, profile } = c;
let [one, two, three] = [1, "hello", true];

함수의 반환값

return 문을 기준으로 반환값의 타입 또한 잘 추론된다.

function func() {
  return "hello";
} // string 반환값

기본값이 설정된 매개변수

아무것도 없는 매개변수와 달리 기본값이 설정되어 있는 매개변수의 경우 기본값을 기준으로 타입을 추론할 수 있다.

function func(msg = "hello") {
  return "hello";
}

주의해야 하는 상황

암시적으로 any 타입으로 추론되는 변수

변수를 선언할 때 초기값을 생략하면 암시적 any 타입으로 추론된다. 이때는 매개변수의 타입이 암시적 any로 추론될 때와 달리 일반 변수의 타입이 암시적 any 타입으로 추론되는 상황은 오류로 판단하지 않는다.

또한 다음과 같이 암시적 any 타입 변수에 값을 할당하면 그 다음 라인부터 암시적 any 타입이 할당한 값의 타입으로 변화한다.

let d;
d = 10;
d.toFixed();

d = "hello";
d.toUpperCase();
d.toFixed(); // 오류

이렇게 암시적으로 추론된 any 타입은 코드의 흐름에 따라 타입이 계속 변화한다. 이를 any의 진화라고 표현하기도 한다.

const로 선언된 상수도 타입 추론이 진행되는데, let으로 선언한 변수와는 다르게 값을 변경할 수 없는 상수로 인식해 가장 좁은 타입인 리터럴 타입으로 추론된다.

const num = 10;
// 10 Number Literal 타입으로 추론

const str = "hello";
// "hello" String Literal 타입으로 추론

최적 공통 타입

다음과 같이 다양한 타입의 요소를 담은 배열을 변수의 초기값으로 설정하면 타입스크립트는 자동으로 최적의 공통 타입으로 추론한다.

let arr = [1, "string"];
// (string | number)[] 타입으로 추론

타입 단언

타입 단언을 설명하기 위해서 다음 코드를 살펴보자.

type Person = {
  name: string;
  age: number;
};

let person: Person = {}; // 오류 발생
person.name = "";
person.age = 23;

위 코드에서 변수 person을 Person 타입의 빈 객체를 넣어두고 싶은 개발자의 의도가 보이는가..? 하지만 타입스크립트에서는 이런 경우를 허용하지 않는다. 빈 객체는 Person 타입이 아니기 때문이다.

이럴 땐 다음과 같이 이 빈 객체를 Person 타입이라고 타입스크립트에게 딱 잘라 말해주면 된다. "아니 내가 Person 타입이라고 하면 Person 타입인거지 어? 딱 잘라 말할게. 이 빈 객체, Person 타입이다." 그리고 이러한 행위를 ‘주저하지 아니하고 딱 잘라 말하다 = 단언하다’, 타입 단언이라고 한다.

타입 스크립트에서는 값 as 타입으로 특정 값을 원하는 타입으로 단언할 수 있다. 이를 활용해 위 상황을 해결한다면 다음과 같다.

type Person = {
  name: string;
  age: number;
};

let person = {} as Person;
person.name = "";
person.age = 23;

타입 단언은 빈 객체를 선언할 때 뿐만 아니라 초과 프로퍼티 검사를 피할 때에도 요긴하게 사용될 수 있다. 앞서 변수를 객체 리터럴로 초기화할 때 초기화하는 변수의 타입의 프로퍼티를 객체 리터럴이 초과할 경우 초과 프로퍼티 검사에서 에러가 발생한다고 했다. 이를 타입 단언을 통해 해결하면 다음과 같다.

type Dog = {
  name: string;
  color: string;
};

let dog: Dog = {
  name: "돌돌이",
  color: "brown",
  breed: "진도",
} as Dog;

위 코드의 객체 리터럴에는 breed라는 초과 프로퍼티가 존재하지만 Dog 타입으로 단언했기 때문에 초과 프로퍼티 검사를 피했다.

타입 단언의 조건

타입 단언에도 조건이 있다.

값 as 타입 형식의 단언식을 A as B라고 표현했을 때 아래의 두 가지 조건 중 한 가지를 반드시 만족해야 한다.

  • A가 B의 슈퍼타입이다.
  • A가 B의 서브타입이다.

다음 예시 코드를 살펴보고 이해해보자. (타입 계층도는 이미 잘 안다고 가정한다.)

let num1 = 10 as never; // ✅
let num2 = 10 as unknown; // ✅
let num3 = 10 as string; // ❌

다중 단언

타입 단언은 다중으로도 가능하다. 다중 단언을 이용하면 앞서 ‘타입 단언의 조건’ 장에서 살펴본 예제 중 불가능했던 단언도 다음과 같이 가능하도록 만들 수 있다.

let num3 = 10 as string; // ❌
let num3 = 10 as unknown as string; // ✅

이렇게 타고타고 모든 타입의 슈퍼타입인 unknown까지 올라갔다가 다시 string으로 내려와버리는 것이다. 하지만 이렇게 단언하는 것은 매우 좋지 않은 방식이다. 타입 단언은 실제로 그 값을 해당 타입의 값으로 바꾸는 것이 아니라 단순한 눈속임에 불과하다. 타입스크립트에게 "믿어라 10 이거 스트링이다."라고 가스라이팅 하는 것이란 말이다.

이러한 방식으로 슈퍼-서브 타입 관계를 갖지 않는 타입으로 단언할 경우 오류가 발생할 확률이 매우 높아지므로 정말 어쩔 수 없이 필요한 상황에서만 이용할 것을 권장한다.

const 단언

타입 단언 때에만 사용할 수 있는 const 타입이 존재한다. 특정 값을 const 타입으로 단언하면 마치 변수를 const로 선언한 것과 비슷하게 타입이 변경된다.

let num4 = 10 as const;
// 10 Number Literal 타입으로 단언됨

let cat = {
  name: "야옹이",
  color: "yellow",
} as const;
// 모든 프로퍼티가 readonly를 갖도록 단언됨

Non Null 단언

Non Null 단언은 지금까지 살펴본 값 as 타입형식을 따르지 않는 단언이다. 값 뒤에 느낌표(!)를 붙여주면 이 값이 undefined이거나 null이 아닐 것으로 단언할 수 있다.

type Post = {
  title: string;
  author?: string; // author 있을 수도 있고? 없을 수도 있습니다.
};

let post: Post = {
  title: "게시글1",
  // 없네요..
};

const len: number = post.author!.length; // 있어! 있어! 있어! 없어도 있어!

사실 단언은 한 마디로 타입스크립트 가스라이팅하는 목소리 큰 개발자…인 것이다.


타입 좁히기 (타입 가드)

다음과 같은 함수가 하나 있다고 하자.

function func(value: number | string) {}

이때 매개변수 value의 타입이 number | string이므로 함수 내부에서 다음과 같이 value가 number 타입이거나 string 타입일 것으로 기대하고 메소드를 사용하려고 하면 오류가 발생한다.

function func(value: number | string) {
  value.toFixed(); // 오류
  value.toUpperCase(); // 오류
}

이렇게 여러 타입을 가질 수 있는 변수에게 특정 타입의 메소드를 사용하고 싶다면 다음과 같이 조건문을 이용해 value의 타입이 number타입이나 string타입임을 보장해주어야 한다.

function func(value: number | string) {
  if (typeof value === "number") {
    console.log(value.toFixed());
  } else if (typeof value === "string") {
    console.log(value.toUpperCase());
  }
}

이렇게 조건문을 이용해 조건문 내부에서 변수가 특정 타입임을 보장하면 조건문 내부에서는 변수의 타입이 보장된 타입으로 좁혀진다. 따라서 첫 번째 조건문 내부에서는 value의 타입이 number 타입이 되고, 두 번째 조건문 내부에서는 string 타입이 된다. 이를 타입 좁히기라고 표현한다.

또한 typeof처럼 조건문과 함께 사용해 타입을 좁히는 이런 표현들을 타입 가드라고 부른다. 정리하자면 타입 가드를 이용해 타입을 좁혀 사용할 수 있다.

타입가드

instanceof 타입 가드

instanceof를 사용하면 다음과 같이 내장 클래스 타입을 보장할 수 있는 타입가드를 만들 수 있다.

function func(value: number | string | Date) {
  if (value instanceof Date) {
    console.log(value.getTime());
  }
}

하지만 instanceof는 내장 클래스 혹은 직접 만든 클래스에만 사용이 가능하다. 따라서 우리가 직접 만든 타입과는 함께 사용할 수 없다.

우리가 직접 만든 타입과 함께 사용하려면 in 타입 가드를 사용한다.

in 타입 가드

다음과 같이 in 연산자와 프로퍼티를 활용해 직접 만든 타입으로 좁힐 수 있다.

type Person = {
  name: string;
  age: number;
};

function func(value: number | string | Date | null | Person) {
  if (typeof value === "number") {
    console.log(value.toFixed());
  } else if (typeof value === "string") {
    console.log(value.toUpperCase());
  } else if (value instanceof Date) {
    console.log(value.getTime());
  } else if (value && "age" in value) {
    console.log(`${value.name}${value.age}살 입니다`);
  }
}

서로소 유니온 타입

서로소 유니온 타입은 말그대로, 교집합이 없는 서로소 관계의 타입들을 모아 만든 유니온 타입이다.

다음과 같은 간단한 회원 관리 프로그램이 있다고 가정하자.

type Admin = {
  name: string;
  kickCount: number;
};

type Member = {
  name: string;
  point: number;
};

type Guest = {
  name: string;
  visitCount: number;
};

type User = Admin | Member | Guest;

function login(user: User) {
  if ("kickCount" in user) {
    // Admin
    console.log(`${user.name}님 현재까지 ${user.kickCount}명 추방했습니다`);
  } else if ("point" in user) {
    // Member
    console.log(`${user.name}님 현재까지 ${user.point}모았습니다`);
  } else {
    // Guest
    console.log(`${user.name}님 현재까지 ${user.visitCount}번 오셨습니다`);
  }
}

회원의 역할 분류에 따라 3개의 타입을 정의해 준 뒤, 이 3개의 타입의 유니온 타입인 User 타입도 만들어 주었다.

login 함수는 User 타입의 매개변수 user를 받아 in 타입가드를 통해 타입을 좁힌 뒤 좁혀진 타입에 맞는 명령문을 실행한다.

그러나 이렇게 코드를 작성하면 조건식만 보고 어떤 타입으로 좁혀지는지 바로 파악하기가 힘들다. 즉, 작성한 사람만이 즉시 알아볼 수 있는 직관적이지 못한 코드이다.

이럴 때에는 다음과 같이 각 타입에 태그 프로퍼티를 추가로 정의해준다.

type Admin = {
  tag: "ADMIN";
  name: string;
  kickCount: number;
};

type Member = {
  tag: "MEMBER";
  name: string;
  point: number;
};

type Guest = {
  tag: "GUEST";
  name: string;
  visitCount: number;
};

각각의 타입에 String Literal 타입의 tag 프로퍼티를 추가해 주었다. 이를 활용해 login 함수의 타입가드를 다음과 같이 더 직관적으로 수정할 수 있다.

function login(user: User) {
  if (user.tag === "ADMIN") {
    console.log(`${user.name}님 현재까지 ${user.kickCount}명 추방했습니다`);
  } else if (user.tag === "MEMBER") {
    console.log(`${user.name}님 현재까지 ${user.point}모았습니다`);
  } else {
    console.log(`${user.name}님 현재까지 ${user.visitCount}번 오셨습니다`);
  }
}

또는 switch를 이용해 더 직관적으로 변경할 수도 있을 것이다.

function login(user: User) {
  switch (user.tag) {
    case "ADMIN": {
      console.log(`${user.name}님 현재까지 ${user.kickCount}명 추방했습니다`);
      break;
    }
    case "MEMBER": {
      console.log(`${user.name}님 현재까지 ${user.point}모았습니다`);
      break;
    }
    case "GUEST": {
      console.log(`${user.name}님 현재까지 ${user.visitCount}번 오셨습니다`);
      break;
    }
  }
}

출처

위 포스트는 이정환 님의 인프런: 한 입 크기로 잘라먹는 타입스크립트(TypeScript)를 수강한 뒤 복습 차원에서 저의 생각을 정리 및 추가하여 업로드했음을 알립니다.

댓글남기기