TypeScript: 타입 추론과 타입 단언

TypeScript 를 도입하기가 망설여지는 이유 중 하나는 매번 일일이 변수를 선언할 때마다 타입을 선언해야하고 필요한 타입을 정의해야하는 비용에 대한 걱정일 것이다. 필요한 타입이 있을 때 타입을 정의하는 일은 정적 타이핑 환경에서는 피할 수 없는 문제지만 타입 선언에 대한 압박은 타입 추론이 얼마나 잘 되느냐에 따라 그 정도가 매우 다르다.

전통적인 정적 타입 언어에서는 모든 변수나 파라미터, 프로퍼티 등의 타입을 모두 선언해야하기 때문에, 타입 추론 같은 기능이 필요없다. 하지만 TypeScript 에서는 모든 변수에 항상 타입을 선언할 필요가 없으므로 컴파일러가 타입 추론을 잘해주기만 해도 타입 선언 비용이 상당히 줄어든다.

타입 추론(Type Inference)

타입 추론의 기본적인 동작을 간단한 예제를 통해서 알아보자.

1
2
3
4
let bool = true;
const arr = [1, 2, 3];
const tuple = [true, 1];
bool = 1; // Error!

위의 예제에서 각 변수의 타입은 적절하게 추론되므로 다시 사용할 때 Type Safe 하게 사용할 수 있다. bool의 타입은 boolean으로 추론되므로 number 타입인 1을 할당하려고 하면 에러가 발생한다. arr 변수는 number[], 즉 number 타입의 배열로 추론된다. 이 때 배열의 길이는 항상 고정이 아닌 것으로 추론한다. 즉, 투플이 아니다. 아래의 tuple 변수도 마찬가지로 배열로 추론된다. 만약 투플로 사용하고 싶다면 별 수 없이 타입 선언을 해주어야 한다. 다만 배열의 요소가 각각 booleannumber이기에 최종적으로 추론되는 타입은 booleannumber유니온 타입의 배열, 코드로 나타내면 (boolean | number)[]이다.

여기에서 배열에서 사용된 요소들의 타입을 각각 추론하여 유니온 타입으로 만들어 내는 방식을 TypeScirpt 에서는 Best common type이라고 부른다.

Best common type

Best common type 은 말 그대로 가장 일반적인 타입이다. 여러가지 자료형이 배열 내부에서 사용되고 있을 때, 그 여러가지 자료형을 포괄할 수 있는 가장 일반적인 자료형을 추론하는 것이다. 그 결과로 위의 예제에서는 true1을 포괄할 수 있는 자료형인, (boolean | number)가 추론된 것이다.

Best common type 은 대부분의 경우에 유니온 타입으로 추론되지만 예외적인 케이스도 몇 가지 있다. 먼저 특정 클래스가 다른 클래스를 상속한 경우.

1
2
3
4
5
6
7
8
9
class Parent {
foo = '';
}

class Child extends Parent {
bar = '';
}

const arr = [new Parent(), new Child()];

위의 예제에서 arr 변수의 타입은 Parent[]로 추론된다. Parent 타입이 Child 타입을 포괄할 수 있는 타입이므로 그렇게 추론되는 것이다. 하지만 다음의 예제에서는 조금 다르다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Parent {
foo = '';
}

class Child1 extends Parent {
bar = '';
}

class Child2 extends Parent {
baz = '';
}

const arr = [new Child1(), new Child2()];

여기에서도 분명 Parent 타입은 Child1 타입과 Child2 타입을 포괄할 수 있는 타입이다. 따라서 마찬가지로 Parent[]로 타입이 추론되는 것이 타당해 보이지만, 실제로는 다시 유니온 타입으로, (Child1 | Child2)[]로 추론된다. 이유는 모르겠지만 아마 약간의 기술적인 어려움이 있는 것이 아닐까 예상된다.

함수가 상황에 따라 여러가지 타입의 값들을 리턴하는 경우에서도 리턴 값이 위와 동일한 룰을 따라 추론된다. 다만 상수를 리턴하면 그 값 자체가 타입으로 인식되는 리터럴 타입으로 추론된다.

타입 단언(Type Assertion)

TypeScript 의 타입 추론 기능은 매우 강력하지만 어쩔 수 없는 한계가 존재한다. 타입 단언은 TypeScript 컴파일러가 타입을 실제 런타임에 존재할 변수의 타입과 다르게 추론하거나 너무 보수적으로 추론하는 경우에 프로그래머가 수동으로 컴파일러한테 특정 변수에 대해 타입 힌트를 주는 것이다.

다음 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Character {
hp: number;
runAway() {
/* ... */
}
isWizard() {
/* ... */
}
isWarrior() {
/* ... */
}
}

class Wizard extends Character {
fireBall() {
/* ... */
}
}

class Warrior extends Character {
attack() {
/* ... */
}
}

function battle(character: Character) {
if (character.isWizard()) {
character.fireBall(); // Property 'fireBall' does not exist on type 'Character'.
} else if (character.isWarrior()) {
character.attack(); // Property 'attack' does not exist on type 'Character'.
} else {
character.runAway();
}
}

이 코드는 컴파일 에러를 낸다. Character 클래스에는 fireBall, attack 메소드가 선언조차 되어있지 않기 때문이다. 하지만 프로그래머 입장에서 바라보면 isWizard라는 메소드를 통해 확실히 그 캐릭터가 Wizard 인스턴스라는 걸 보장할 수 있다면, if 블록 안에서는 당연히 fireBall이라는 메소드를 사용할 수 있어야 한다.

이 때, 타입 단언으로 적절한 타입을 다시 선언해줄 수 있다.

1
2
3
4
5
6
7
8
9
function battle(character: Character) {
if (character.isWizard()) {
(character as Wizard).fireBall(); // Pass
} else if (character.isWarrior()) {
(character as Warrior).attack(); // Pass
} else {
character.runAway();
}
}

해당 변수가 실제로 Wizard 인스턴스가 아니더라도 as 키워드를 통해서 타입 단언을 해줄 수 있기 때문에, 타입 단언은 주의해서 사용해야 한다. 실제로도 as any 라는 치트키로 대부분의 컴파일 에러를 해결할 수 있다. 하지만 이런 키워드가 코드 베이스에 득시글 거릴 수록 TypeScript 를 사용해서 얻는 장점이 점차 사라져가기 때문에, 기왕 TypeScript 를 사용하기로 마음 먹었다면 asany는 가능한 적게 사용하는 것이 좋다. 물론, 아예 사용하지 않기는 어렵다.

타입 단언이 타입 캐스팅이 아닌 이유

타입 단언은 타입을 변경한다는 사실 때문에 타입 캐스팅과 비슷하게 느껴질 수 있다. 타입 단언이 타입 캐스팅이라고 불리지 않는 이유는 런타임에 영향을 미치지 않기 때문이다. 타입 캐스팅은 컴파일타임과 런타임에서 모두 타입을 변경시키지만 타입 단언은 오직 컴파일타임에서만 타입을 변경시킨다.

<Type> vs as Type

타입 단언 문법은 <Type>as Type 으로 두 종류다. 아래처럼 사용할 수 있다.

1
2
(<Wizard>character).fireBall();
(character as Wizard).fireBall();

그냥 보기에는 <Type> 키워드가 좀 더 깔끔해보이지만, 대개 as Type 키워드가 추천된다. React 와 React 에서 빼놓을 수 없는 문법인 JSX 를 사용하는 경우 <Type> 키워드는 JSX 의 문법과 겹치기 때문에 불편한 면이 있다.

타입 가드(Type Guard)

타입 가드는 타입 단언을 좀 더 깔끔하게 할 수 있도록 도와준다. 앞서 타입 단언에서 소개한 예제에서는 isWizard라는 메소드로 해당 인스턴스가 해당 타입이라는 사실을 확정했다. 하지만 이건 런타임에서만 알 수 있는 사실이고 TypeScript 컴파일러는 알 수 없었다. 타입 가드는 이러한 런타임에서의 타입 체크를 컴파일러에게 알려주는 기능이다.

1
2
3
4
5
6
7
8
class Character {
isWizard(): this is Wizard {
return this instanceof Wizard;
}
isWarrior(): this is Warrior {
return this instanceof Warrior;
}
}

이런식으로 런타임에서 실제 타입검사를 하는 메소드의 리턴타입으로 {variable} is {Type} 같은 문법을 사용해 선언해주면 된다.

1
2
3
4
5
6
7
8
9
function battle(character: Character) {
if (character.isWizard()) {
character.fireBall(); // Pass
} else if (character.isWarrior()) {
character.attack(); // Pass
} else {
character.runAway();
}
}

이제 별도의 타입 단언 문법 없이도 if 블록 안에서 characterWizardWarrior로 잘 추론된다. 그리고 사실 instanceoftypeof 같은 오퍼레이터가 일종의 타입 가드이기도 하다.

1
2
3
4
5
6
7
8
function doSomething(val: string | number) {
if (typeof val === 'number') {
val.toFixed(); // Pass, val은 number 타입으로 추론
} else {
// Union 타입에서 `number`는 이미 통과했으므로 자동으로 `string`으로 추론됨
val.toLowerCase(); // Pass, val은 string 타입으로 추론
}
}

사족: 타입 선언은 언제하는가?

별도의 선언이 없어도 추론이 잘 되는 경우에는 타입 선언을 하지 않고, 그렇지 않은 경우에만 선언하는 것이 편리하고 자연스럽다. 예를 들면,

1
const regex: RegExp = new RegExp(/pattern/);

위와 같은 상황에서 : RegExp 라는 코드는 불필요한 선언으로 간주하고 지운다. 하지만 다음처럼 변수 생성과 동시에 값을 할당하지 않는 경우,

1
2
let regex = null;
regex = new RegExp(/pattern/);

이런 상황에서는 regex 변수의 타입 추론이 제대로 되지 않으므로 타입을 같이 선언해준다.