[tsconfig의 모든 것] Compiler options / Type Checking

    [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 문 등에 이름을 붙히는 것이 가능해지며, 이렇게 이름을 붙힌 문의 이름을 통해 해당 문으로 접근하여 breakcontinute 등의 명령으로 제어할 수 있게 해준다.

    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 문에서 ij가 둘 다 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 함수 내부의 thisany타입으로 바인딩하게 되고, 이로 인해 타입 안정성이 와장창 깨지게 된다.

    즉, 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 타입보다 작은 개념이기 때문이다.

    unionset string | number 타입은 string과 number가 합쳐진 합집합을 의미한다.

    이처럼 string | number 타입은 두 개의 타입이 합쳐진 합집합을 의미하고 있으니, 당연하게도 string 타입은 자신보다 더 큰 개념인 string | number 타입을 품을 수 없다.

    이 경우, 작은 개념인 string 타입을 string | number 타입의 “서브 타입”이라고 부르고, 큰 개념인 string | numberstring 타입의 “슈퍼 타입”이라고 부른다. (참고로 부분 집합, 상위 집합도 영어로 subset, superset이다. 사실 상 여기서 이름을 따온 것이라고 보면 된다.)

    이와 동일하게 Array<string> 또한 Array<string | number> 타입보다 작은 개념이다. string만 가지고 있도록 선언한 배열에 stringnumber를 모두 가진 배열을 할당할 수는 없는 노릇이니 타입스크립트는 에러를 발생시킨다.

    이처럼 어떠한 타입 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개의 멤버 변수를 가지고 있는데, nameemail은 값이 반드시 존재해야하는 타입으로 선언된 멤버 변수이고, 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 문의 인자인 eany 타입으로 평가하기 때문에, 이 값을 가지고 어떤 짓을 하던 컴파일 타임에는 아무 에러도 발생하지 않는 것이다. 하지만 만약 enull이나 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는 다음과 같다.



    만약 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 인터페이스는 명확하게 선언된 speedquality라는 프로퍼티와, [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 객체의 프로퍼티들에 접근하고 있다. 물론 명확하게 선언된 speedquality 프로퍼티는 반드시 존재하는 것이 확실하지만, 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 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.