이번 글부터는 TS의 문법을 기초부터 차근차근 알아볼 예정이다. 해당 시리즈의 목차 자체는 TypeScript 공식문서 Handbook에서 가져왔으며, 대부분의 예시 코드와 흐름도 해당 핸드북의 내용을 따라갈 생각이다. 물론 직접 TS를 사용해가면서 느낀 점이나 실제 사용에 있어서 알아두면 좋은 부분 등을 추가하여 작성하고 있다. 역시 무언가 새로운 것을 배울 때에는 공식문서를 꼼꼼히 읽어보고 시작하는 것이 좋다.
앞서 우리는 TS가 JS를 강타입 언어로 튜닝해주는 문법이라고 하였으며, 이는 런타임에서만 타입에러를 확인할 수 있는 JS를 컴파일 단계에서도 확인할 수 있도록 해주는 데에 이점이 있다고 이야기하였다. 이 때 이러한 타입 에러를 런타임 이전부터 감지하려고 하는 TS의 컴파일러를 TS컴파일러, TSC라고 칭한다. 간단하게 우리가 앞으로 TS를 사용할 때, 함수의 작동이나 실질적 동작들은 JS 엔진에게 맡기고, 변수나 함수의 타입은 TSC와 함께 핸들링하는 과정을 가진다고 생각하면 쉽다.
일단은 가장 기본적으로 TS에서 가장 general하게 다루고 있는 타입에 대해서 알아보자. 앞으로 다양한 커스텀 타입과 복잡한 타입 생성 문법들을 배우겠지만, 기본적으로는 모두 JS에서 다루고 있는 타입에서부터 시작되니 JS 표준 데이터 타입을 살펴보고 시작하는 것이 바람직한 순서로 보인다.
해당 타입들은 MDN Web Docs의 Javscript Data Type 문서, JavaScript가 따르고 있는 자료형 표준을 포함한 ECMAScript에 기재된 내용을 바탕으로 작성되었다.
위의 나열한 자료형들을 포함하여 코드에서 타입 활용의 유틸성을 위하여 TS에서는 다양한 기본 타입들을 제공한다. 타입 추정과 관련된 문법을 살펴본 뒤, 다음 편인 Everyday Types에서 TS는 어떠한 방법으로 기본 자료형들에 대한 선언을 하는지 더 자세히 알아보도록 하자.
다음으로는 TS의 강타입 적용 구문을 어떻게 작성하는지에 대해서 살펴보자. 적용 구문 자체에도 여러 종류가 있지만, 가장 general하게 쓰이게 될 타입 적용에 대해서 말이다.
message.toLowerCase();
JS엔진의 입장에서 위의 코드를 작동시키기 위해, 어떤 것들을 고려해야 할지 생각해보자.
적어도 위의 사항들에서는 타입 에러가 발생할 수 있기 때문에, TS는 이를 방지하기 위해 위와 같은 사항들이 실행되려는 코드에 존재한다는 것을 인식한다. 물론 일반적인 JS스타일의 선언/할당문으로 message에 string type의 값이 할당되었을 경우에는 에러를 반환하지 않는다. toLowerCase()는 string prototype의 property로 string type을 반환하는 함수이기 때문이다.
☝🏻 TS는 여러 가지 방법으로 변수의 타입을 추정한다.
코드 내에서 선언된 임의의 변수에 대하여 TS는 특정한 시점부터 변수의 타입을 추정하게 되며, 런타임에 진입하기 전부터 이러한 타입 추정을 통해 에러를 반환한다. 지금부터 이런 TS가 변수 혹은 함수의 타입을 추정하기 시작하는 시점들을 알아보자.
let message: string;
message = 1;
// Type Error: Type 'number' is not assignable to type 'string'.
첫 번째 케이스는 변수나 함수의 선언과 동시에 타입을 함께 선언해주는 케이스이다. 내가 지금까지 TS를 사용하면서 가장 많이 사용하게 된 첨자가 바로 타입 선언 첨자인 “:”이다.
타입 선언을 통해 우리는 TS에게 이 변수는 어떤 데이터 타입을 가진다고 선언할 수 있다. 타입 선언이 한 번 이루어진 변수에 대해서는 그 이후에 해당 타입을 위배하는 값을 할당하거나 해당 타입이 가지지 않는 시그니처 호출 등을 명령하였을 때 등, 런타임 타입 에러를 발생시킬 수 있는 부분들에 대하여 컴파일 단계에서 에러를 출력하도록 한다. 함수에서도 마찬가지로 활용할 수 있다.
const myFunction = (arg:number):string => {
return arg;
}
// Type 'number' is not assignable to type 'string'.
Arrow Function 형태의 함수 선언에 대해서는 위와 같이,
function myFunction(arg:number):string{
return arg;
}
// Type 'number' is not assignable to type 'string'.
Regular Function 형태의 함수 선언에 대해서는 위와 같이 파라미터의 타입과 반환 타입을 선언해줄 수 있다.
let message = 'Hello, World!';
message = 1;
// Type Error: Type 'number' is not assignable to type 'string'.
두 번째 케이스는 선언된 변수 또는 함수에 특정 타입의 값이 할당되기 시작한 시점이다. 위 코드 블럭의 첫 번째 문장에서의 선언/할당문으로 message에 string type의 값이 할당된 순간부터 TS는 ‘message 변수에는 string type만이 할당될 수 있다.’고 기억하기 시작하며, 위와 같이 다른 type의 값을 할당하려고 하면 에러를 뱉어낸다. 이러한 TS의 특성은 함수에서도 적용된다.
const numToStr = (num:number) => {
return `${num}`
}
let number1 = 1
let number2 = 2
number2 = numToStr(number1);
// Type 'string' is not assignable to type 'number'.
위와 같이 number 값이 할당된 적이 있는 number2 변수의 경우 데이터 타입을 number로 정해놓고 있기 때문에, 반환 타입이 string인 numToStr 함수의 반환값을 할당하였을 때 타입 에러를 표시해준다.
const myCanvas:HTMLCanvasElement = document.getElementById("main_canvas");
// Type Error: Type 'HTMLElement | null' is not assignable to type 'HTMLCanvasElement'.
// Type 'null' is not assignable to type 'HTMLCanvasElement'.
세 번째 케이스는 공식 문서의 표현을 빌리자면 “TypeScript보다 당신이 어떤 값의 타입에 대한 정보를 더 잘 아는 경우”이다. 타입 단언 변수의 선언과 함께 해당 변수의 타입이 무엇인지 TSC에게 명시해주는 것으로, 위의 케이스에서 getElementById의 반환타입은 HTMLElement이거나 null인데, HTMLCanvasElement로 타입이 확정되어있는 변수인 myCanvas에 해당 반환 값을 할당하려 하니, TSC는 런타임 에러가 발생할 수 있는 가능성을 캐치하여 우리에게 타입 에러를 던져준다.
만약에 우리가 이런 식으로 인터페이스를 호출할 때 인터페이스가 지정해놓은 타입값이 아닌, 우리가 생각하는 특정한 타입 값을 반환한다는 것을 확신한다면, 아래와 같이 타입 단언 문법을 활용할 수 있다.
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");
위의 두 문장은 모두 getElementById 함수의 반환값이 HTMLCanvasElement일 것을 확신한 상태로 TSC에게 myCanvas의 타입은 예외 없이 HTMLCanvasElement일 것이라 명시해준다.
🔥 우리는 타입스크립트를 사용하면서 다양한 상황에서 변수의 타입을 타입스크립트에게 알리고 배우게 된다. 근데 위에서 살펴볼 때도 그렇고, 실제로 사용해보면 동작하는 데에 크게 문제가 생기지 않기 때문에 타입 선언과 타입 단언은 가리키는 단어만 다르지 크게 다른 게 없어 보인다.
하지만 둘은 분명하게 다르다. 우리가 확실하게 둘을 구분해야 하는 이유는 “에러 발생 가능성”에 있다.
타입 선언을 통해 선언된 변수의 타입을 TSC는 철저하게 지켜낸다. 하지만 타입 단언의 경우 실제로 다른 타입의 값을 입력하며 엉뚱한 타입의 단언을 해준다면 TSC는 그것을 단언된 타입이라고 믿게 되고, 단언된 타입으로 변수를 취급하게 된다. 타입 단언의 잘못 사용된 예를 한 번 살펴보자.
const getSomething1 = ():number | number[] => {
return [1,2]
}
const number1 = getSomething1() as number
console.log(number1 / 2)
위에서는 타입 단언을 통해 “getSomething() 호출의 반환 값은 number이고, number1에 이거 할당할 거야.”라는 뉘앙스로 TSC에게 타입을 단언해준다. getSomething 함수의 반환형은 number 또는 number array이다.
TSC는 실제 getSomething() 함수의 반환이 number array형인데도 불구하고 number1은 number type을 갖는다고 믿게된다.
위의 코드는 TypeScript Error를 발생시키지 않고, 무사히 실행시킬 수 있다. 실행해보면 런타임에러가 발생하진 않지만, NaN이 출력되게 된다. ‘Not A Number’이다. 물론 런타임에러를 발생시키는 경우도 충분히 작성할 수 있다.
const getSomething2 = ():number | number[] => {
return 100
}
const array1 = getSomething2() as number[]
console.log(array1.filter(() => { return 1 }))
// "Executed JavaScript Failed: array1.filter is not a function"
결과적으로 우리는 타입 단언으로 TSC를 속였고, 런타임 타입 에러를 발생시켰다.
만약 위의 코드에서 array1에 할당할 수 있는 값은 number array형 밖에 없다. 혹은 그 위의 코드에서 number1에 할당할 수 있는 값은 number형 밖에 없다는 식의 ‘타입 선언’을 해주었다면 실행 자체를 TSC에서 막아주었을 것이다.
const getSomething1 = ():number | number[] => {
return [1,2]
}
const number1:number = getSomething1()
console.log(number1 / 2)
// Type 'number | number[]' is not assignable to type 'number'.
// Type 'number[]' is not assignable to type 'number'.
const getSomething2 = ():number | number[] => {
return 100
}
const array1:number[] = getSomething2()
console.log(array1.filter(() => { return 1 }))
// Type 'number | number[]' is not assignable to type 'number[]'.
// Type 'number' is not assignable to type 'number[]'.
런타임에 발생할 수 있는 타입 에러를 사전에 방지하는 것이 우리가 TS를 쓰는 이유이기 때문에, 타입 선언을 통한 타입 핸들링이 사용 목적에 맞아 보인다. 다들 이야기하듯이, 왠만하면 타입 단언보다는 타입 선언으로 모든 것을 해결하는 것이 좋다는 뜻이다.
물론 위의 타입 단언에 대한 설명에서와 같이 “TypeScript보다 당신이 어떤 값의 타입에 대한 정보를 더 잘 아는 경우”, 인터페이스 함수를 통해 가져온 자료형이 내가 원하거나 확실하게 반환되는 것이 보장되는 타입보다 넓어서 Narrow한 타입에 대한 메소드 호출이 불가한 경우 등에는 타입 단언이 적합한 경우도 있다.
npm에 등록되어 있는 패키지들 중에서는 다양한 자료형을 union하여 반환하는 함수들도 포함된 패키지들이 있다. 이러한 함수들의 반환 자료형들 때문에 다음과 같은 상황이 발생할 수 있는데, 이런 상황이 타입 단언을 활용하기에 적절한 예시로 생각된다.
const getSomething1 = ():number | number[] => {
return 1
}
const number1:number = getSomething1()
// Type 'number | number[]' is not assignable to type 'number'.
// Type 'number[]' is not assignable to type 'number'.
이번에는 getSomthing1 함수의 반환형이 심지어 number인데도 불구하고 number1의 할당에 타입 에러를 반환한다.
우리는 getSomething1 함수의 반환값이 무조건 number형이라는 것을 알고 있으며, TSC는 getSomething1의 반환 타입이 number[]도 될 수 있다고 믿고 있는 상황이다. “TypeScript보다 당신이 어떤 값의 타입에 대한 정보를 더 잘 아는 경우”인 것이다. 이럴 때는 다음과 같이 타입 단언을 통해 에러를 해결할 수 있다.
const getSomething1 = ():number | number[] => {
return 1
}
const number1 = getSomething1() as number
자, 이렇게 타입 단언이 필요한 적절한 예시까지 살펴보며 타입 선언과 타입 단언의 차이를 좀 더 자세하게 알아보았다. 최대한 잘못 사용하는 일을 피하도록 해보자. 사실 왠만하면 타입 단언을 사용하지 말라고 이야기해주고 싶다. 타입 단언을 남발하는 것은 any를 남발하는 타입스크립트 사용과 크게 차이가 없을 수도 있기 때문이다.
또한 분명히 타입 단언이 사용될 수 있음에도 불구하고 타입 선언으로 모든 것을 해결하고 싶다면, 타입 가드에 대해서 정확하게 알고 이를 적절하게 활용해야 할 것이다. 당장에 다음 편부터는 다양한 타입 선언 예약어들에 대해서 살펴보고, 타입 가드와 관련해서는 나중에, 프로젝트에서 직접 활용하고 있는 type narrower function과 함께 소개해볼 생각이다.