본문 바로가기
Language/Typescript

타입스크립트 : 타입의 함정 (Read와 Write의 타입추론)

by hsloth 2024. 6. 23.

 

 

평화롭게 코딩을 하던 어느날.

Nest.js 톡방의 "주발자"라는 분께서 한 가지 의문을 던져주셨다.

바로 다음 코드에서 obj2[key] = value; <<< 이 부분에 왜 에러가 발생하는지 모르겠다고 물어보셨다.

 

코드를 적어 놓을테니 해보실 분들은 해보시길 바랍니다.

type Entries2<T> = {
  [K in keyof T]-?: [K, T[K]];
}[keyof T][];

interface Test {
  test1: string;
  id?: number;
}

const obj: Test = {
  test1: "hoi",
  id: 0,
};

const obj2: Test = {
  test1: "",
  id: undefined,
};

type A = Entries2<Test>;

for (const arr of Object.entries(obj) as Entries2<Test>) {
  const [key, value] = arr;

  obj2[key] = value;

  if (key === "test1") {
    obj2[key] = value;
  }
}

 

 

위 코드를 복붙하면, 다음과 같은 에러가 발생한다.

 

string | number | undefined는 never에 할당될 수 없다고 나온다.

 

obj2[key]의 타입은 string | number | undefined이고 value의 타입도 string | number | undefined인데 어째서 에러가 발생하는걸까???

 

 

위 코드에서 중점적으로 봐야할 부분은

  1. Entries2<Test>는 어떤 타입인지
  2. for문 안의 key, value의 타입은 어떤식으로 추론되는지

위 두 가지를 중점적으로 보면 된다.

 

 

먼저, Entries2<Test>의 타입은 다음과 같다.

 

A의 경우, ["test1", string] 혹은 ["id", number | undefined] 를 원소로 갖는 배열타입이다.

즉, 다음과 같이 예를 들 수 있다.

const exampleA: A = [
  ["id", 1],
  ["test1", "str"],
  ["test1", "str2"],
  ["id", undefined],
];

 

 

for문에서의 변수인 arr의 타입은 A의 원소의 타입과 같으므로

 

arr을 구조분해할당한 [key, value]의 타입은 ["test1", string] | ["id", number] 라고 볼 수 있다.

 

그렇다면 여기서 key와 value의 타입은 어떤식으로 추론이 될까?

변수 value는 자신의 타입을 결정함에 있어서 key에게 의존하고 있다.

key가 "test1"이면 value의 타입은 string이고, key가 "id"이면 value의 타입은 number | undefined이다.

 

그러면, key가 "test1"이라면 value의 타입은 string으로 추론되어야하고, key가 "id"라면 value의 타입은 number | undefined로 추론되어야한다.

하지만, 타입시스템은 컴파일에서만 동작하고 key가 "test1"를 값으로 가질지 "id"를 값으로 가질지는 런타임에 결정되기 때문에 위와 같은 값에 따른 분기 형식의 타입추론은 불가능하다.

따라서 타입스크립트의 타입시스템에서는 key를 "test1" | "id" 로 추론하고, value를 string | number | undefined 로 추론을 하게 된다.

 

문제는 여기서 발생한다.

만약, key와 value를 ["test1", string] | ["id1", number | undefined] 에 알맞게 각각 추론을 할 수 있다면, obj2[key] = value에서 문제가 발생하지 않을 것이다.

하지만 key가 "test1" | "id" 타입으로 추론되기 때문에 컴파일러의 입장에서는 value의 타입을 확신할 수 없다.

key가 "test1" 이면 obj2[key]는 string 타입일 것이고,

key가 "id"이면 obj2[key]는 number | undefined 타입일 것이기 때문이다.

 

따라서 value는 string | number | undefined로 추론되기 때문에

만약 key가 "test1"이라면, value가 string이 아닌 경우가(value가 number | undefined일 수도) 있기 때문에 에러가 발생하고

만약 key가 "id"라면, value가 number | undefined가 아닐 수도(value가 string일 수도) 있기 때문에 에러가 발생한다.

 

따라서 해당 코드가 정상적으로 동작하게 하기 위해서는 if문을 통해 key의 값에 따른 분기처리를 해주어야 한다.

for (const arr of Object.entries(obj) as Entries2<Test>) {
  const [key, value] = arr;

  if (key === "test1") {
    obj2[key] = value;
  } else {
    obj2[key] = value;
  }
}

 

이렇게 해주어야 key와 value의 타입이 정상적으로 추론되는 것을 볼 수 있다.

 


이 글의 핵심


이 글의 핵심은 두 가지다.

첫 째, 구조분해할당 등으로 인해 변수의 타입이 의존성을 띌 경우(한 변수의 타입이 다른 변수의 타입에 따라 달라지는 경우), 유니온 타입으로 바뀌게 된다.

const tuple: [1, number] | [2, string]

const [a, b] = tuple

a: 1 | 2
b: number | string

 

 

둘 째, Read를 위한 타입과 Write를 위한 타입에 대한 타입 추론이 다르게 동작한다. 

여기서는 한 가지 예를 들어보면서 설명을 해볼까한다.

 

type TestObject = {
  hello: string;
  bye: number;
};

const a = "hello" as "hello" | "bye";
const testObj: TestObject = {
  hello: "good",
  bye: 2,
};

const z = testObj[a];

testObj[a] = 1;

 

위 코드에서도 아래와 같이 에러가 발생한다.

 

z = testObj[a] 에서

a는 "hello" | "bye",

z는 string | number로 추론되지만

정작 a는 "hello"이기 때문에 

testObj[a] = 1을 대입할 수 없다는 예이다.

 

즉, testObj[a]의 타입은 Read를 할 때는 string | number지만, testObj[a] 타입에 어떤 값을 Write할 때는 a에 따라 testObj[a]의 타입이 다르기 때문에 never로 추론이 되어서 값을 대입할 수 없게 된다.