[tsconfig의 모든 것] Compiler options / Type Checking
이번 포스팅에서는 tsconfig
의 컴파일 옵션의 타입 체킹 옵션들에 대한 이야기를 해보려고 한다.
사실 타입스크립트를 사용하는 이유는 대부분 자바스크립트가 제공하지 않는 강력한 타입 시스템이 가져다 주는 안정성 때문이므로 수없이 많은 컴파일 옵션들 중에서도 타입 체크에 대한 옵션들은 타입스크립트의 가장 핵심 기능을 관리하는 옵션들이라고 할 수 있다.
그만큼 타입스크립트는 “이런 코드는 금지야”라는 느낌의 단순한 옵션부터 타입 시스템의 원리를 알고 있어야 동작을 제대로 이해할 수 있는 옵션까지, 굉장히 다양한 옵션들을 제공하고 있다.
대부분의 타입체크 컴파일 옵션들은 모두 타입스크립트의 타입 안정성을 더욱 단단하게 만들어 줄 수 있는 옵션들이기 때문에, 타입스크립트 팀에서도 가급적 모든 옵션을 켜는 것을 추천하고 있다.
하지만 타입 안정성이 단단해진다는 것을 반대로 말하면, 평소라면 그냥 any
타입을 타고 어물쩍 넘어갈 수 있었던 것들도 허용되지 않는다는 의미이다.
즉, 타입스크립트를 점진적으로 도입하고 있는 어플리케이션에서는 타입 에러만 고치다가 많은 시간을 허비할 수도 있기 때문에, 현재 내가 속해있는 조직의 비즈니스 상황까지 잘 고려하여 옵션을 켜는 것을 추천한다.
그럼 이제 타입스크립트의 타입 체킹 컴파일 옵션들이 실제로 내 코드에 어떤 영향을 끼치는 지에 대해서 한번 자세히 알아보도록 하자.
Unreachable, Unused
Unreachable, Unused
라는 이름으로 시작하는 옵션들은 소스코드에서 사용하지 않거나, 절대로 실행될 수 없는 코드를 어떻게 관리할 것인지에 대한 옵션들이다.
allowUnreachableCode
값 | 설명 |
---|---|
undefined (default) |
도달 할 수 없는 코드를 만나면 경고를 띄운다. |
true |
도달 할 수 없는 코드를 만나도 무시한다. |
false |
도달 할 수 없는 코드를 만나면 에러를 발생시킨다. |
allowUnreachableCode
옵션은 컴파일러가 도달 할 수 없는 코드를 만났을 때 어떻게 반응할 것인지에 대한 설정을 의미한다. 도달할 수 없는 코드란, 다시 말해 실행될 수 없는 코드라는 뜻이다.
function foo (value: number) {
if (value > 5) {
return true;
} else {
return false;
}
// Unreachable code detected.ts(7027)
return true;
}
foo
함수는 if-else
문에서 어떤 조건에 걸리던 무조건 값을 반환하고 함수를 종료시키는 동작을 가지고 있기 때문에, 함수 맨 마지막 라인의 return true
구문은 절대로 실행될 수가 없다. 바로 이 부분이 도달 할 수 없는 코드, Unreachable 코드인 것이다.
이 옵션은 기본 값으로만 설정해도 저런 코드를 무시하는 것이 아니라 경고를 띄우도록 되어있기 때문에 딱히 해당 옵션을 켜지 않아도 어느 정도는 Unreachable 코드가 발생하는 상황을 방어할 수는 있다.
그래도 저런 의미없는 구문이 소스코드에 남아있다면 개발자가 어플리케이션의 흐름을 잘못 이해하는 경우가 발생할 수도 있고, 저런 코드는 어차피 죽은 라인이라 그냥 지워버려도 어플리케이션에 어떠한 영향을 주지 않기 때문에, 되도록이면 옵션을 켜고 컴파일 타임 때 에러가 나도록 설정하는 것을 추천한다. (물론 알 수 없는 이유로 저런 코드를 지웠는데 버그가 나는 경우도 간혹 있…)
allowUnusedLabels
값 | 설명 |
---|---|
undefined (default) |
사용하지 않는 라벨을 만나면 경고를 띄운다. |
true |
사용하지 않는 라벨을 만나도 무시한다. |
false |
사용하지 않는 라벨을 만나면 에러를 발생시킨다. |
allowUnusedLabels
옵션은 컴파일러가 코드 내에서 사용하지 않는 라벨을 만났을 때 어떻게 반응할 것인지에 대한 설정을 의미한다.
프로그래밍을 시작한 지 오래 되지 않은 분들은 이 “라벨”이라는 것이 익숙하지 않으실 수도 있는데, 사실 이 라벨이라는 기능은 타입스크립트에만 있는 것은 아니고 다른 언어들에도 있는 기능이다.
필자는 6년 전 쯤 학교에서 C를 처음 배울 때 라벨을 사용해보기는 했는데, 그 이후로는 딱히 사용해본 적이 없다.
라벨은 특정 문(statements)을 대상으로 하기 때문에 if
문, for
문 등에 이름을 붙히는 것이 가능해지며, 이렇게 이름을 붙힌 문의 이름을 통해 해당 문으로 접근하여 break
나 continute
등의 명령으로 제어할 수 있게 해준다.
let i, j;
outerLoop:
for (i = 0; i < 3; i++) {
innerLoop: // Unused Label
for (j = 0; j < 3; j++) {
if (i === 1 && j === 1) {
// i도 1이고 j도 1이면 바깥 루프를 건너뛴다.
continue outerLoop;
}
console.log(`i=${i}, j=${j}`);
}
console.log(`${i + 1}번 루프 끝`);
}
i=0, j=0
i=0, j=1
i=0, j=2
1번 루프 끝
i=1, j=0
i=2, j=0
i=2, j=1
i=2, j=2
3번 루프 끝
위 예시를 보면 안 쪽에 위치한 for
문에서 i
와 j
가 둘 다 1
인 상황을 만나면 바깥 쪽 for
문을 건너뛰도록 동작하고 있다. 그래서 출력을 확인해보면 i=1, j=0
이 찍힌 이후 “n번 루프 끝”이라는 로그도 출력되지 않고 바로 세번째 순회를 실행하는 모습을 확인할 수 있다.
이 때, 바깥 쪽 for
문의 라벨인 outerLoop
는 안 쪽 for
문에서 사용된 것을 볼 수 있지만, 안 쪽 for
문의 라벨인 innerLoop
는 어디서도 사용되지 않았다. 이것이 바로 Unused Label인 것이고, 우리는 allowUnusedLabels
옵션을 통해 타입스크립트 컴파일러가 이러한 사용되지 않은 라벨을 만났을 때 어떻게 반응할 지를 설정할 수 있는 것이다.
하지만 라벨은 대부분 위에서 밑으로 실행되는 프로그램의 실행 흐름을 역행하게 만드는 주범이기도 하고, 함수처럼 명확한 스코프를 가지고 관심사가 분리된 녀석도 아니기 때문에, 가독성을 해쳐 개발자가 프로그램의 흐름을 읽기 어렵게 만드는 주범이다. (애초에 라벨 + goto 문은 어셈블리 쓰던 시절에 나온 개념이라 C에서도 안티패턴이라고 싫어한다. )
그러니 이 옵션을 켤지 말지를 고민하는 것이 아니라, 아예 라벨 자체를 사용하지 않는 것을 추천한다.
noUnusedLocals
값 | 설명 |
---|---|
true |
사용하지 않는 로컬 변수가 있다면 에러를 발생시킨다. |
false (default) |
사용하지 않는 로컬 변수가 있어도 무시한다. |
noUnusedLocals
옵션은 함수 내부에 사용하지 않는 지역 변수가 존재할 경우 어떻게 처리할 지에 대한 옵션이다. 만약 이 옵션이 true
일 경우, 컴파일러는 사용하지 않는 지역 변수에 대해 에러를 발생시킨다.
const foo = () => {
// 'bar' is declared but its value is never read.ts(6133)
const bar = 1;
}
noUnusedLocals
옵션은 옵션 이름에 충실하게 함수 스코프가 아닌, 전역 스코프나 모듈 스코프에 있는 변수는 사용하지 않더라도 어떠한 에러나 경고도 발생시키지 않으니, 이 점을 유의하도록 하자.
noUnusedParameters
값 | 설명 |
---|---|
true |
사용하지 않는 인자가 있다면 경고를 발생시킨다. |
false (default) |
사용하지 않는 인자가 있어도 무시한다. |
noUnusedParameters
옵션은 함수의 인자 중 사용하지 않는 것이 있을 경우 어떻게 처리할 지에 대한 옵션이다. 만약 이 옵션이 true
일 경우, 컴파일러는 사용하지 않는 인자에 대해 에러를 발생시킨다.
// 'name' is declared but its value is never read.ts(6133)
const foo = (name: string) => {
return '';
}
암묵적인 선언 금지
타입스크립트의 타입 체킹 컴파일 옵션들을 보면 noImplicit
라는 이름으로 시작하는 많은데, 이 의미는 “암묵적인 무언가”를 금지한다는 말이다.
암묵적이라는 말은 결국 어떤 정보도 제공하지 않은채 몰래 뭔가를 수행한다는 의미이기 때문에, 개발자가 예측하지 못한 곳에서 문제가 발생할 가능성이 높아지는 것이다.
그러니 되도록 noImplicit
로 시작하는 옵션들을 모두 켜서, 타입스크립트 컴파일 타임에 나 몰래 뭔가를 알아서 평가해버리는 상황을 최대한 방어하도록 하자.
noImplicitAny
값 | 설명 |
---|---|
true |
타입 추론이 불가능하고 타입 선언도 되지 않은 값을 암묵적으로 any 타입으로 사용하는 것을 금지한다. |
false (default) |
타입 추론이 불가능하고 타입 선언도 되지 않은 값을 암묵적으로 any 타입으로 사용하는 것을 허용한다. |
타입스크립트는 딱히 개발자가 타입을 명시적으로 선언하지 않았고, 추론도 불가능한 값에 대해서 any
타입으로 평가하는데, noImplicitAny
옵션은 이렇게 any
타입으로 평가된 값이 존재할 때 에러를 발생시킬 지 여부를 결정하는 옵션이다.
물론 이렇게 any
타입으로 평가된 값이 존재한다면 타입 안정성에 좋지 않은 영향을 끼치기 때문에, 타입스크립트는 이 옵션을 켜지 않아도 any
타입에 대해서 경고를 띄워주기는 한다.
하지만 단순 경고는 컴파일 타임 때 그냥 무시하고 넘어가게 되기 때문에, noImplicitAny
옵션을 사용하여 이런 any
타입에 대해서 아예 컴파일 타임 때 에러를 발생시키고 컴파일을 막아버리는 편이 훨씬 안전하다.
function fn(s) {
// Parameter 's' implicitly has an 'any' type.ts(7006)
console.log(s.subtr(3));
}
noImplicitOverride
값 | 설명 |
---|---|
true |
서브 클래스의 암묵적인 오버라이딩을 금지한다. |
false (default) |
서브 클래스의 암묵적인 오버라이딩을 허용한다. |
noImplicitOverride
옵션은 클래스를 상속받은 서브 클래스가 슈퍼 클래스의 멤버 변수나 메소드를 암묵적으로 오버라이딩하는 것을 허용할 것인지에 대한 옵션이다.
class Album {
download() {}
upload() {}
}
class SharedAlbum extends Album {
// This member must have an 'override' modifier because it overrides a member in the base class 'Album'.ts(4114)
download() {}
// overriding 키워드를 사용한 명시적 오버라이딩은 허용
override upload() {}
}
만약 이런 암묵적인 오버라이딩이 발생했을 때 컴파일러가 아무런 경고 또는 에러를 띄워주지 않는다면, 개발자가 인지하지 못한 채 슈퍼 클래스의 기능을 오버라이딩 해버리는 상황이 발생할 수 있다. (슈퍼 클래스의 모든 멤버 변수와 메소드 이름을 외우고 있는 것도 아니니 말이다)
이 경우 개발자가 의도치 않게 슈퍼 클래스의 기능을 훼손하여 예상하지 못한 에러가 발생할 수 있으므로, 가급적이면 암묵적인 오버라이딩을 금지하는 noImplicitOverride
을 켜고 개발하는 것을 추천한다.
noImplicitReturns
값 | 설명 |
---|---|
true |
함수가 암묵적으로 undefined 를 반환하는 상황을 금지한다. |
false (default) |
함수가 암묵적으로 undefined 를 반환하는 상황을 허용한다. |
noImplicitReturns
옵션은 함수가 암묵적으로 undefined
를 반환하는 상황을 어떻게 처리할 것인지에 대한 옵션이다.
// Not all code paths return a value.ts(7030)
function foo (v: number) {
if (v === 1) {
return true;
}
}
console.log(foo(2)); // undefined
위 예시의 foo
함수는 자신이 받은 인자가 1
일 경우에는 true
를 반환하고 그 외에는 아무것도 반환하지 않기 때문에 타입스크립트는 이 함수가 boolean
타입의 값을 반환한다고 추론한다.
하지만 실제로는 foo(2)
와 같이 조건에 맞지 않는 인자와 함께 함수를 호출하게 되면 아무것도 명시적으로 반환하지 않고 함수가 종료되면서, 암묵적으로 undefined
가 반환되게 된다. 이 동작은 브라우저의 개발자 도구에서 아무 함수가 선언한다음 호출해보면 바로 확인해볼 수 있으니 한번 해보도록 하자.
여기서 문제는 타입스크립트가 추론한 함수의 반환 타입은 분명 boolean
타입이지만, 실제로는 암묵적인 반환 타입인 undefined
가 합쳐져 최종적으로 boolean | undefined
가 반환될 수 있다는 것이다. 결국 이 모순으로 인해 런타임 때 예상하지 못한 에러가 발생할 수 있는 빌미를 제공하게 된다.
그래서 noImplicitReturns
옵션은 이렇게 함수가 암묵적으로 undefined
를 반환하는 상황일 경우, 함수가 값을 제대로 반환하고 있지 않다는 경고를 보여주는 역할을 한다. 해당 옵션을 켜는 경우, 위 예시의 foo
함수는 명시적으로 undefined
를 반환하는 형태로 변경되어야 한다.
function foo (v: number) {
if (v === 1) {
return true;
}
return undefined;
}
noImplicitThis
값 | 설명 |
---|---|
true |
this 가 암묵적으로 any 로 평가되는 경우를 금지한다. |
false (default) |
this 가 암묵적으로 any 로 평가되는 경우를 허용한다. |
noImplicitThis
옵션은 this
가 암묵적으로 any
로 평가되는 경우를 금지한다. 이 옵션의 동작을 이해하려면 우선 자바스크립트나 타입스크립트의 this
가 어떤 녀석인지 알아야 할 필요가 있지만, 이 포스팅은 this
의 동작에 대한 주제가 아니므로 간단하게만 설명하고 넘어가도록 하겠다. this
에 대해 더 깊은 지식을 원하시는 분은 이 포스팅을 참고하도록 하자.
기본적으로 자바스크립트의 this
라는 녀석은 동적으로 바인딩 된다. 함수가 어떤 방식으로 호출되냐에 따라서 이 this
가 가리키고 있는 것이 달라진다는 것이다.
class Human {
sayThis () {
console.log(this);
}
}
const evan = new Human();
const sayThis = evan.sayThis;
evan.sayThis(); // Human {}
sayThis(); // globalThis
위 예시를 보면, 같은 Human
클래스의 메소드인데도 호출 방법에 따라 this
가 동적으로 변경되고 있는 것을 확인할 수 있다. 자바스크립트의 this
는 이처럼 예측하기 어려운 동작을 하기 때문에 개발자가 직접 this
가 무엇인지를 정할 수 있는 call
, apply
, bind
와 같은 메소드들이나 this
바인딩 자체를 하지 않는 ES6의 화살표 함수 같은 녀석들이 필요한 것이다.
바로 이 동적 바인딩이라는 방식 때문에 타입스크립트가 this
의 타입 추론을 할 수 없는 상황도 왕왕 발생하는데, 그 중 대표적인 예가 함수 안의 함수, 흔히들 이야기하는 Inner Function에서 this
에 접근하는 경우이다.
class Human {
sayThis () {
function say () {
// 'this' implicitly has type 'any' because it does not have a type annotation.ts(2683)
console.log(this);
};
say();
}
}
const evan = new Human();
evan.sayThis(); // globalThis
위 예시를 보면 sayThis
메소드 내부에서 다시 say
함수를 선언한 후 호출하고 있고, 안 쪽에 선언된 함수에서 this
에 접근하고 있다. 상식적으로 생각하면 이 함수의 this
는 왠지 Human
클래스가 되어야 할 것 같지만, 현실은 시궁창처럼 globalThis
가 튀어나온다. (이런 경우에는 화살표 함수 씁시다…)
바로 이런 경우가 타입스크립트가 this
에 대한 추론을 하지 못하는 경우이다. 이때 타입스크립트는 암묵적으로 say
함수 내부의 this
를 any
타입으로 바인딩하게 되고, 이로 인해 타입 안정성이 와장창 깨지게 된다.
즉, noImplicitThis
옵션은 이렇게 this
타입이 암묵적으로 any
로 평가되는 상황을 용인할 것인지에 대한 옵션인 것이다. 애초에 메소드 내부에 선언된 함수의 this
에 전역 객체가 바인딩되는 동작을 응용하는 상황 자체가 거의 없으므로 이 옵션 또한 가급적이면 켜는 것을 추천한다.
Strict
Strict
옵션들은 타입스크립트가 일반적인 상황에서 타입을 평가할 때 얼마나 엄격하게 평가할 것인지를 관리하는 옵션들이다.
이런 옵션들을 타입스크립트 팀에서는 Strict mode family라고 호칭하고 있는데, 간단하게는 자바스크립트의 'use strict'
디렉티브로 켤 수 있는 Strict 모드의 동작을 위반했는지 검사하는 옵션부터, 타입의 공변성과 반공변성을 검사하는 옵션까지, 다양한 상황에서 타입스크립트가 조금 더 엄격하게 타입을 평가할 수 있는 옵션들을 제공하고 있다.
alwaysStrict
값 | 설명 |
---|---|
true |
소스 코드 내에서 Strict 룰을 위반하는 방법을 사용하면 에러를 발생시킨다. |
false (default) |
소스 코드 내에서 Strict 룰을 위반하는 방법을 사용해도 무시한다. |
alwaysStrict
옵션은 개발자가 타입스크립트를 사용할 때 자바스크립트의 Strict 룰을 위반하는 방법을 사용했을 때 에러를 발생시킨다. 이 옵션을 true
로 설정하게 되면 타입스크립트는 각 소스 파일들의 상단에 마치 use strict
디렉티브가 선언되어 있는 것처럼 구문을 파싱하게 된다.
참고로 타입스크립트의 대부분의 Strict 모드 관련 옵션들은 “모듈이 아닌 코드”를 위한 것이다. 타입스크립트는 ECMAScript 2015의 Strict Mode Code 섹션의 정의에 따라 모든 모듈 코드는 반드시 Strict 모드로 컴파일하기 때문이다.
이 옵션을 사용할 때 한 가지 알아둬야 할 점은 use strict
디렉티브를 “소스 파일”에 내보내는 것이지, 결과물에 내보낸다는 것이 아니라는 것이다. 예시를 한번 보도록 하자.
// index.ts
// Octal literals are not allowed in strict mode.ts(1121)
const number = 023;
// index.js
var number = 023;
위 예시 같은 경우 IDE에서 Octal literals are not allowed in strict mode.ts(1121)
라는 에러를 보여주기는 하지만, 정작 컴파일을 할 때는 아무 에러가 발생하지 않고 컴파일 후에 생긴 결과 파일을 확인해도 use strict
디렉티브는 포함되어 있지 않은 것을 볼 수 있다. 즉, 소스 코드를 파싱할 때는 에러를 인지하지만, 컴파일 결과에서까지 Strict 모드가 적용되는 것은 보장하지 않는 것이다.
하지만 앞서 말한대로 모듈을 사용하고 있을 경우, 타입스크립트는 해당 옵션의 여부와 상관없이 소스 코드 파싱 및 컴파일 결과물에도 Strict 모드를 적용하게 된다.
// index.ts
export const number = 023;
// index.js (common.js)
"use strict";
exports.__esModule = true;
exports.n = void 0;
exports.n = 023;
console.log(exports.n);
strictBindCallApply
값 | 설명 |
---|---|
true |
call , apply , bind 를 사용하여 함수를 호출했을 때에도 인자의 타입을 검사한다. |
false (default) |
call , apply , bind 를 사용하여 함수를 호출했을 때에도 인자의 타입을 검사하지 않는다. |
strictBindCallApply
옵션은 call
, apply
, bind
와 같이 함수의 실행 컨텍스트를 변경할 수 있는 메소드를 사용하여 함수를 호출하거나 선언을 했을 때에도 함수에 올바른 타입의 인자가 주어졌는지를 검사할 수 있는 옵션이다.
function sayHi(name: string) {
return `Hi, ${name}`;
}
sayHi.call(undefined, "evan");
// Argument of type 'boolean' is not assignable to parameter of type 'string'.ts(2345)
sayHi.call(undefined, false);
물론 함수의 실행 컨텍스트를 변경하는 일이 자주 발생하는 것은 아니지만, 이 옵션이 꺼져있는 상태에서 call
, apply
, bind
를 사용하게 되면 타입스크립트는 함수의 인자에 제대로 된 타입이 넘겨졌는지 검사하지 않기 때문에, 이로인한 런타임 타입 에러가 발생할 수도 있다.
strictFunctionTypes
값 | 설명 |
---|---|
true |
함수의 인자가 반공변적인 타입으로 평가된다. |
false (default) |
함수의 인자가 이변적인 타입으로 평가된다. |
strictFunctionTypes
옵션은 함수의 인자를 반공변적으로 평가되게 할 것인지에 대한 옵션이다. 이 포스팅은 공변성과 반공변성에 대한 주제가 아니니, 공변과 반공변에 대해 자세히 알고 싶으신 분들은 이 포스팅을 한번 보고 오도록 하자.
이 포스팅에서는 공변과 반공변에 대해서 간략하게만 설명하고 넘어가도록 하겠다.
let foo: Array<string> = [];
let bar: Array<string | number> = [];
/*
Type '(string | number)[]' is not assignable to type 'string[]'.
Type 'string | number' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.ts(2322)
*/
foo = bar;
위 예시에서 필자는 Array<string>
인 foo
변수에 Array<string | number>
타입인 bar
변수의 값을 할당하려고 했고, 타입스크립트는 string | number
타입은 string
타입인 값에 할당할 수 없다고 말하고 있다.
왜 에러가 발생하는 것일까? 그 이유는 string
타입이 string | number
타입보다 작은 개념이기 때문이다.
이처럼 string | number
타입은 두 개의 타입이 합쳐진 합집합을 의미하고 있으니, 당연하게도 string
타입은 자신보다 더 큰 개념인 string | number
타입을 품을 수 없다.
이 경우, 작은 개념인 string
타입을 string | number
타입의 “서브 타입”이라고 부르고, 큰 개념인 string | number
을 string
타입의 “슈퍼 타입”이라고 부른다. (참고로 부분 집합, 상위 집합도 영어로 subset, superset이다. 사실 상 여기서 이름을 따온 것이라고 보면 된다.)
이와 동일하게 Array<string>
또한 Array<string | number>
타입보다 작은 개념이다. string
만 가지고 있도록 선언한 배열에 string
과 number
를 모두 가진 배열을 할당할 수는 없는 노릇이니 타입스크립트는 에러를 발생시킨다.
이처럼 어떠한 타입
T
가 타입T'
의 서브 타입인 경우,C<T>
또한C<T'>
의 서브 타입이라면 이때 타입C
를 공변적, 또는 공변성을 가지고 있다고 이야기한다.
공변성이 무엇인지 이해했다면 나머지는 쉽다. 반공변성은 공변성의 반대이기 때문이다. 즉 타입 T
가 타입 T'
의 서브 타입일 때, 반대로 C<T'>
가 C<T>
의 서브 타입이 된다면, 이때 타입 C
를 반공변적이라고 한다.
이렇게 타입이 반공변적으로 평가되어야 하는 경우가 있을까 싶기도 한데, 의외로 가까운 곳에 있다. 바로 strictFunctionTypes
옵션이 다루고 있는 주체인 함수의 인자이다.
type Func<T> = (x: T) => void;
let stringOrNumber: Func<string | number> = (x) => {};
let onlyString: Func<string> = (x) => {};
/*
Type 'Func<string>' is not assignable to type 'Func<string | number>'.
Type 'string | number' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.ts(2322)
*/
stringOrNumber = onlyString;
앞서 보았던 공변성 예시에서 string
타입은 string | number
의 서브 타입이라고 이야기 했었다.
만약 함수의 인자가 공변적으로 평가된다면, Func<string>
또한 Func<string | number>
의 서브 타입이 되어야한다는 의미이기 때문에 Func<string>
타입에 Func<string | number>
타입을 할당해도 문제가 발생하지 않아야 한다.
하지만 이 예시를 보면 그게 아니라는 것을 알 수 있다. Func<string | number>
는 분명 슈퍼 타입인 string | number
를 인자로 받는 함수 타입이지만, 이 타입에 인자로 서브 타입인 string
만을 받는 Func<string>
타입의 함수를 할당하려고 하면 타입스크립트는 에러를 발생시킨다.
즉, 함수의 인자는 반공변적으로 평가되기 때문에, 공변성을 가지고 있는 Array
타입과는 정반대로 작동하고 있는 것이다.
이 동작이 어렵게 느껴질 수도 있지만 잘 생각해보면 이해가 되는 것이, onlyString
함수는 Func<string>
타입이기 때문에 인자가 반드시 string
타입일 것이라고 가정하고 정의된 함수이기 때문이다.
만약 이런 함수를 string | number
타입의 인자를 받을 수 있다는 함수라고 인정해버린다면, 개발자가 실수로 onlyString
함수에 number
타입의 인자를 넘길 수도 있다는 것이고, 이 경우 onlyString
함수는 장렬한 런타임 타입 에러와 함게 전사할 것이다.
별 모양도 어떻게든 끼울 수 있다고 설명서를 제공하는 꼴이랄까...
이런 함수의 특성으로 인해 함수의 인자는 반공변적으로 평가되는 것이 훨씬 안전하기 때문에, 타입스크립트는 strictFunctionTypes
옵션을 제공하고 있는 것이다.
만약 이 옵션이 꺼져있다면 함수의 인자가 반공변적으로 평가되지 않기 때문에 위 예시에서 보았던 할당의 제약이 모두 사라지게 된다. 이렇게 되면 앞서 말한대로 개발자의 실수로 함수의 인자로 잘못된 타입을 넘겨 런타임 에러가 발생할 확률이 커지게 되므로 가급적이면 strictFunctionTypes
을 켜도록 하자. (생각보다 크게 불편하지도 않다)
strictNullChecks
값 | 설명 |
---|---|
true |
구체적인 값이 존재해야하는 상황에서 값이 null 이거나 undefined 일 가능성이 존재하는 경우 에러를 발생시킨다. |
false (default) |
구체적인 값이 존재해야 하는 상황에서, 값이 null 이거나 undeinfed 일 가능성이 존재해도 무시한다. |
strictNullChecks
옵션은 구체적인 값이 반드시 존재해야하는 상황일 때, 어떠한 값이 null
이거나 undefined
일 가능성이 존재한다면 에러를 발생시키는 옵션이다.
구체적인 값이 반드시 존재해야하는 상황은 대부분 foo.a
처럼 어떤 객체의 프로퍼티에 접근하려고 시도하는 상황이라고 볼 수 있는데, 이렇게 접근할 때 만약 foo
변수의 값이 null
이거나 undefined
라면 레퍼런스 런타임 에러가 발생하게 된다.
interface Person {
name: string;
}
const people: Person[] = [{ name: 'evan' }];
const myPerson = people.find(({ name }) => name === 'john');
// Uncaught ReferenceError: myPerson is not defined
console.log(myPerson.name);
위 예시에서 사용한 Array.prototype.find
메소드는 T | undefined
타입을 반환하도록 정의되어있는데, 이는 find
메소드가 배열에서 목표로 했던 원소를 찾을 수도 있고 없을 수도 있기 때문이다.
그렇기 때문에 이 예시에서의 myPerson
변수는 Human | undefined
의 타입을 가지게 되며, 실제로 위 코드를 실제로 실행시켜보면 이름으로 evan
이라는 값을 가지고 있는 원소가 배열 내에 없기 때문에 결국 myPerson
변수의 값은 undefined
가 되고, 마지막 콘솔 라인에서 런타임 레퍼런스 에러가 발생하게 된다.
만약 strictNullChecks
옵션이 꺼져있는 상태라면 이런 상황이라도 타입스크립트는 값이 undefined
일 가능성이 존재하는 myPerson
의 프로퍼티에 접근할 때 어떠한 경고나 에러도 주지 않지만, 옵션을 켜게되면 다음과 같은 에러가 발생하게 된다.
interface Person {
name: string;
}
const people: Person[] = [{ name: 'evan' }];
const myPerson = people.find(value => value.name === 'john');
// Object is possibly 'undefined'.ts(2532)
console.log(myPerson.name);
이런 상황에서는 명시적으로 if
문을 사용하여 myPerson
변수가 null
또는 undefined
인지 검사하거나, myPerson?.name
처럼 옵셔널 체이닝을 사용하여 프로퍼티에 접근함으로써 타입 안정성을 유지하며 문제를 해결할 수 있다.
strictPropertyInitialization
값 | 설명 |
---|---|
true |
객체가 생성된 시점에 반드시 존재해야 하는 멤버 변수가 초기화되지 않았다면 에러를 발생시킨다. |
false (default) |
객체가 생성된 시점에 반드시 존재해야 하는 멤버 변수가 초기화되지 않았어도 무시한다. |
strictPropertyInitialization
옵션은 클래스의 멤버 변수가 안전하지 않은 타입을 가지게 되었을 때 어떻게 처리할 지에 대한 옵션이다.
class UserAccount {
name: string;
// Property 'email' has no initializer and is not definitely assigned in the constructor.
email: string;
address: string | undefined;
constructor(name: string) {
this.name = name;
}
}
위 예시에서 클래스 UserAccount
는 총 3개의 멤버 변수를 가지고 있는데, name
과 email
은 값이 반드시 존재해야하는 타입으로 선언된 멤버 변수이고, address
는 값이 undefined
일 수도 있는 옵셔널한 타입으로 선언된 멤버 변수이다.
하지만 클래스의 생성자를 보면 name
멤버 변수만 초기화를 하고 있다. 이대로라면 객체가 생성되었을 때 반드시 string
타입의 값을 가지고 있어야 하는 email
멤버 변수가 undefined
로 할당된 채로 객체가 생성되어 버리는 것이다. 즉, 타입 안정성이 깨져버리게 된다.
strictPropertyInitialization
옵션을 사용하면 이렇게 클래스를 사용하여 생성한 객체가 정의된 타입과 맞지 않는 멤버 변수를 가지게 되는 경우에 에러를 발생시키기 때문에, 높은 타입 안정성을 가지고 갈 수 있다.
useUnknownInCatchVariables
값 | 설명 |
---|---|
true |
catch 문의 error 인자를 unknown 타입으로 평가한다. |
false (default) |
catch 문의 error 인자를 any 타입으로 평가한다. |
useUnknownInCatchVariables
옵션은 그 이름이 말해주듯이, catch 문의 인자로 주어지는 error
값을 어떤 타입으로 평가할 지에 대한 옵션이다.
기본적으로 타입스크립트는 Catch 문의 인자를 any
타입으로 평가하기 때문에 이런 느낌으로 코드를 작성하게 된다.
try {
// ...
} catch (e) {
console.log(e.message);
console.log(e.reason);
console.log(e.data);
console.log(e.whatever);
}
기본적으로 아무런 옵션도 주지 않았을 때, 타입스크립트는 catch 문의 인자인 e
를 any
타입으로 평가하기 때문에, 이 값을 가지고 어떤 짓을 하던 컴파일 타임에는 아무 에러도 발생하지 않는 것이다. 하지만 만약 e
가 null
이나 undefined
라면 바로 런타임 레퍼런스 에러가 발생할 수 있는 위험한 상황이다.
이제 useUnknownInCatchVariables
을 켜게 되면 타입스크립트는 이제 catch 문의 인자로 넘어오는 값을 더 이상 any
타입이 아닌, unknown
타입으로 평가하게 된다.
try {
// ...
} catch (e) {
// Property 'message' does not exist on type 'unknown'.ts(2339)
console.log(e.message);
if (e instanceof Error) {
// 이건 통과
console.log(e.message);
}
}
단순히 “무시해라”라는 의미를 가진 any
타입과 다르게, unknown
타입은 “타입을 알 수 없으니 정의가 필요하다”라는 의미를 가지고 있기 때문에, 반드시 타입에 대한 정보를 타입스크립트에게 알려줘야 한다.
물론 이렇게 catch 문의 인자를 unknown
으로 평가하게 되면, catch 문 내부에서 인자의 타입을 체크하는 과정이 필요해지기 때문에 조금 귀찮아 질 수는 있지만, 개인적으로는 예상하지 못 한 순간에 런타임 에러가 터지느니 프로그래밍할 때 조금 귀찮은 게 훨씬 낫다고 생각한다.
strict
값 | 설명 |
---|---|
true |
컴파일러 옵션의 Strict 관련 옵션들을 일괄적으로 켠다. |
false (default) |
컴파일로 옵션의 Strict 관련 옵션들을 개별적으로 켠다. |
strict
옵션은 그 자체로 어떤 역할을 하는 것이 아니라, 다른 Strict 관련 옵션들을 일괄적으로 켜고 끌 수 있는 옵션이다. strict
옵션으로 켜고 끌 수 있는 일명 Strict mode family는 다음과 같다.
- alwaysStrict
- strictBindCallApply
- strictFunctionTypes
- strictNullChecks
- strictPropertyInitialization
- useUnknownInCatchVariables
- noImplicitAny
- noImplictThis
만약 strict
옵션을 true
로 설정하면 Strict mode family 옵션들의 값도 함께 true
로 적용되지만, 만약 개별 옵션을 false
로 오버라이딩한다면 해당 옵션만 끌 수도 있다.
타입스크립트 팀은 추후 배포될 타입스크립트 버전에 추가되는 새로운 Strict mode family 옵션도 모두 strict
옵션으로 관리할 수 있도록 만들 예정이라고 하니, 타입스크립트 버전을 업데이트하게 되면 기존에는 없던 새로운 타입 에러가 발생할 수도 있다는 사실을 유념하도록 하자.
etc
이 섹션은 필자가 옵션들을 정리하면서, 카테고라이징을 하기 애매하다고 생각한 옵션들을 모아둔 섹션이다. 물론 이 옵션들 또한 안전한 프로그래밍에 큰 도움이 되는 옵션들이기 때문에 가급적 켜는 것을 추천한다.
exactOptionalPropertyTypes
값 | 설명 |
---|---|
true |
옵셔널 프리픽스가 정말로 객체 내에 없다는 것을 보장한다. |
false (default) |
옵셔널 프리픽스를 사용한 값에 undefined 를 할당하는 것을 허용한다. |
exactOptionalPropertyTypes
옵션은 ?
프리픽스로 표현되는 타입이나 인터페이스 내부의 옵셔널 프로퍼티를 얼마나 엄격하게 처리할 지를 결정하는 옵션이다. 한번 같이 예시를 보면서 어떤 차이점이 있는지 살펴보도록 하자.
interface UserDefaults {
color?: "dark" | "light";
}
위 인터페이스가 가지고 있는 color
프로퍼티는 ?
로 표현되는 옵셔널 프리픽스와 함께 선언되었기 때문에, 해당 프로퍼티가 인터페이스 내에 존재할 수도 있고 없을 수도 있다는 것을 표현하고 있다. 그렇기 때문에 기본적으로 타입스크립트는 이 프로퍼티의 타입을 dark | light | undefiend
로 평가하게 된다.
여기서 약간 모순이 생기는데, 옵셔널 프로퍼티는 사실 “이 프로퍼티가 있을 수도 있고 없을 수도 있어”라는 의미이지, “이 프로퍼티가 존재는 하는데 값이 undefined
야”라는 의미가 아니기 때문이다. 그러나 타입스크립트에서는 옵셔널로 선언된 프로퍼티에 undefined
를 직접 할당함으로써 옵셔널 프로퍼티의 조건을 만족시킬 수 있다.
const user: UserDefaults = {
color: undefined, // dark | light | undefined
};
하지만 exactOptionalPropertyTypes
옵션을 켜게 되면 이제 이런 행위가 불가능해진다. 개발자는 옵셔널 프로퍼티로 선언된 프로퍼티에 undefined
를 할당할 수 없으며, 만약 할당을 시도했다가는 다음과 같은 에러를 만나며 컴파일러에게 혼쭐이 나게 된다.
const user: UserDefaults = {
// Type 'undefined' is not assignable to type '"light" | "dark"'.ts(2322)
color: undefined,
};
이때 컴파일러가 던진 에러 메세지를 보면, 기존에는 dark | light | undefeind
와 같이 undefined
까지 유니온 타입으로 묶어서 선언하던 방식과 다르게, 타입 자체는 dark | light
으로만 정의되어있고 키의 존재 유무로만 옵셔널 프로퍼티를 판단하는 모습을 볼 수 있다.
noFallthroughCasesInSwitch
값 | 설명 |
---|---|
true |
switch 문 내에 Fallthrough 케이스가 존재하면 경고를 보여준다. |
false (default) |
switch 문 내에 Fallthrough 케이스가 존재해도 무시한다. |
noFallthroughCasesInSwitch
옵션은 Fallthrough, 즉 switch
문이 완료되지 않는 케이스가 존재하는 경우에 경고를 보여줄 지에 대한 옵션이다.
const v: number = 6;
switch(v) {
case 1:
// Fallthrough case in switch.ts(7029)
console.log(1);
case 2:
console.log(2);
break;
}
이 경우 첫 번째 케이스 문이 수행되고 switch
문이 종료되는 것이 아니라, 이에 이어서 두 번째 케이스 문까지 수행되기 때문에, 개발자가 의도하지 않은 동작이 되어버릴 가능성이 존재한다.
이렇게 switch
문을 종료시키지 않고 다음 케이스로 그냥 흘려버리는 케이스를 Fallthrough case
라고 부르는데, C++이나 Swift 같은 언어에서는 이런 케이스에 명시적으로 [[fallthrough]]
와 같은 키워드를 작성하게 함으로써 개발자가 한 눈에 “이 케이스 문은 다음 케이스까지 실행시키겠구나”라는 것을 알 수 있지만, 자바스크립트는 이렇게 명시적으로 Fallthrough case를 표현하는 문법이 없기 때문에 개발자가 실수하기 쉬운 구조이다.
그래서 타입스크립트는 이런 Fallthrough case가 발생했을 때 경고를 보여주는 noFallthroughCasesInSwitch
옵션을 제공함으로써 개발자가 Fallthrough case를 확실하게 인지할 수 있도록 도와주고 있다.
noPropertyAccessFromIndexSignature
값 | 설명 |
---|---|
true |
인덱스 시그니처로 선언된 프로퍼티에 . 문법을 사용하여 접근하는 것을 방어한다. |
false (default) |
인덱스 시그니처로 선언된 프로퍼티에 . 문법을 사용하여 접근하는 것을 허용한다. |
noPropertyAccessFromIndexSignature
옵션은 인터페이스나 타입에 미리 정의되지 않은 프로퍼티에 .
문법을 사용하여 접근하는 것을 방어한다.
물론 아예 정의되지 않은 프로퍼티에 접근한다는 상황은 당연히 에러가 나야하는 상황이지만, 이 옵션이 관여하는 상황은 조금 애매하게 타입 선언이 되어있는 경우이다.
interface GameSettings {
speed: 'fast' | 'medium' | 'slow';
quality: 'high' | 'low';
[key: string]: string;
}
GameSettings
인터페이스는 명확하게 선언된 speed
와 quality
라는 프로퍼티와, [key: string]: string
이라는 인덱스 시그니처 프로퍼티를 가지고 있다. 이렇게 인덱스 시그니처로 선언한 프로퍼티는 키의 타입과 값의 타입이 string
타입이만 하면 모든 것을 허용하겠다는 것과 마찬가지이므로 모호한 타입이라고 하는 것이다.
이런 타입 정의를 바탕으로 개발자가 GameSettings
타입인 객체의 프로퍼티에 접근하는 상황을 생각해보자.
const getSettings = (): GameSettings => {
return {
speed: 'fast',
quality: 'high'
}
};
const settings = getSettings();
settings.speed; // Good
settings.quality; // Good
settings.user; // Good...?
위 예시에서 개발자는 GameSettings
타입인 settings
객체의 프로퍼티들에 접근하고 있다. 물론 명확하게 선언된 speed
와 quality
프로퍼티는 반드시 존재하는 것이 확실하지만, settings.user
는 다르다.
이 프로퍼티는 [key: string]: string
이라는 타입 선언에 의존하고 있는 녀석이기 때문에, 이 값이 명확히 존재한다고 말할 수 없기 때문이다.
noPropertyAccessFromIndexSignature
옵션은 이처럼 반드시 존재하는 프로퍼티는 .
문법을 통해 접근할 수 있게 하되, 존재하지 않을 가능성이 있는 프로퍼티는 settings['user']
와 같은 Index 문법을 사용하여 접근하도록 함으로써 이 두 가지 케이스를 구분하도록 강제한다.
noUncheckedIndexedAccess
값 | 설명 |
---|---|
true |
인덱스 시그니처로 선언된 프로퍼티를 Optional 타입으로 평가한다. |
false (default) |
인덱스 시그니처로 선언된 프로퍼티도 정의된 타입으로만 평가한다. |
noUncheckedIndexedAccess
옵션은 인덱스 시그니처로 선언한 프로퍼티를 어떻게 추론할 것인지에 대한 옵션이다.
기본적으로 타입스크립트는 [key: string]: string
처럼 인덱스 시그니처로 선언된 프로퍼티가 있다면, 딱 선언된 대로만 추론해준다.
interface EnvironmentVars {
[key: string]: string;
}
const env: EnvironmentVars = {};
const nodeEnv = env.NODE_ENV; // string
하지만 앞서 말했듯이 인덱스 시그니처로 선언된 프로퍼티는 실제로는 있을 수도 있고 없을 수도 있는 옵셔널한 값이다. 즉, env.NODE_ENV
의 타입은 사실 string
이 아니라 string | undefined
인 것이다.
noUncheckedIndexedAccess
옵션은 이렇게 인덱스 시그니처로 선언한 프로퍼티를 T | undefined
타입으로 평가하여 혹시나 발생할 수 있는 런타임 레퍼런스 에러를 방지해준다.
마치며
타입스크립트의 타입 체킹 컴파일 옵션들은 높은 타입 안정성을 유지할 수 있도록 도움을 주는 녀석들이지만, 타입스크립트와 같은 정적 타이핑 언어 자체에 익숙하지 않은 개발자에게는 가파른 러닝 커브로 다가올 수도 있는 부분이다.
또한 처음 프로젝트를 생성할 때부터 타입스크립트를 사용하는 경우도 있지만, 자바스크립트를 사용하다가 점진적으로 타입스크립트를 도입하고 있는 조직도 존재하기 때문에, 이렇게 다양한 옵션을 제공하여 개발자가 원하는 정적 타이핑 환경을 구축할 수 있게 해주는 것은 큰 장점이라고 생각한다. (물론 옵션도 공부해야 함…)
다음 포스팅에서는 컴파일 옵션 중에서도 타입스크립트가 컴파일을 수행할 때 “어떤 소스코드를 어떤 방식으로 컴파일하여 결과물을 만들어 낼 지에 관여하는 옵션들”에 대해서 이야기해보도록 하겠다.
이상으로 [tsconfig의 모든 것] Compiler options / Type Checking 포스팅을 마친다.