이펙티브 타입스크립트: 1장 타입스크립트 알아보기

아이템1-5

이펙티브 타입스크립트

동작 원리의 이해와 구체적인 조언 62가지

이펙티브 타입스크립트 표지

1장 타입스크립트 알아보기

아이템 1 타입스크립트와 자바스크립트의 관계 이해하기

  • 타입스크립트는 문법적으로도 자바스크립트의 상위 집합이다.
  • 자바스크립트 프로그램에 문법 오류가 없다면, 유효한 타입스크립트 프로그램이라고 할 수 있다.
  • 자바스크립트 파일이 .js (또는 .jsx) 확장자를, 타입스크립트 파일은 .ts(또는 .tsx) 확장자를 사용한다.
  • 확장자명이 다르지만 그렇다고 자바스크립트와 타입스크립트가 완전히 다른 언어라는 의미는 아니다.
  • main.js > main.ts로 바꾼다고 해도 문제가 없다.
  • 위와 같은 특성때문에 자바스크립트 코드를 타입스크립트로 마이그레이션하는데 엄청난 이점이 된다.
function greet(who: string) {
  console.log('Hello', who);
}

위 코드는 유효한 타입스크립트 프로그램이지만 자바스크립트를 구동하는 Node같은 프로그램에서 실행하면 오류를 출력한다. :string은 타입스크립트에서 쓰이는 타입 구문이기 때문이다. 타입구문을 사용하는 순간부터 자바스크립트는 타입스크립트 영역으로 들어가게 된다.



city 변수가 문자열이라는 것을 알려주지 않아도 타입스크립트는 초깃값으로부터 타입을 추론합니다. toUppercase_citystring

위 코드를 실행하면 타입스크립트의 타입체커는 문제점을 찾아냅니다. toUppercase 타입 시스템의 목표 중 하나는 런타임에 오류를 발생시킬 코드를 미리 찾아내는 것입니다. 눈으로 찾기 어려운 오탈자를 쉽게 찾아내어 오타를 찾는데 시간을 덜 쓸 수 있도록 도와줍니다. toUppercasefix



interface State {
  name: string;
  capital: string;
}
const states: State[] = [
  {name: 'Alabama', capitol: 'Montgomery'},
                 // ~~~~~~~~~~~~~~~~~~~~~
  {name: 'Alaska',  capitol: 'Juneau'},
                 // ~~~~~~~~~~~~~~~~~
  {name: 'Arizona', capitol: 'Phoenix'},
                 // ~~~~~~~~~~~~~~~~~~ 개체 리터럴은 알려진 속성만 지정할 수 있지만
                 //         'State'형식에 'capitol'이 없습니다.
                 //         'capital'을 쓰려고 했습니까?
  // ...
];
for (const state of states) {
  console.log(state.capital);
}

states를 State로 명시적으로 선언하여 의도를 분명하게 하는 것이 좋습니다.

  • 오류가 어디에서 발생했는지 찾을 수 있다
  • 의도를 명확히 해서 타입스크립트가 잠재적 문제점을 찾을 수 있게 도와준다.
  • 타입스크립트의 도움을 받으면 오류가 적은 코드를 작성할 수 있습니다.





toUppercasefix ‘타입스크립트는 자바스트립트의 상위집합이다’라는 문장이 잘못된 것처럼 느껴진다면, 아마도 ‘타입 체커를 통과한 타입스크림트 프로그램’ 영역 때문일 것입니다. 평소 작성하는 타입스크립트 코드가 바로 이 영역에 해당합니다.


요약

  • 타입스크립트는 자바스크립트의 상위집합이다. 다시 말해, 모든 자바스크립트 프로그램은 이미 타입스크립트 프로그램입니다.
    반대로, 타입스크립트는 별도의 문법을 가지고 있기 때문에 일반적으로 유효한 자바스크립트 프로그램이 아닙니다.
  • 타입스크립트는 자바스크립트 런타임 동작을 모델링하는 타입 시스템을 가지고 있기 때문에 런타임 오류를 발생시키는 코드를 찾아내려고 합니다.
    타입 체커를 통과하면서도 런타임 오류를 발생시키는 코드는 충분히 존해할 수 있습니다.
  • 타입스크립트 타입 시스템은 전반적으로 자바스크립트 동작을 모델링합니다. 그러나 잘못된 매개변수 개수로 함수를 호출하는 경우처럼, 자바스크립트에서는 허용되지만 타입스크립트에서는 문제가 되는 경우도 있습니다.





아이템2 타입스크립트 설정 이해하기

function add(a, b) {
    return a + b;
}
add(10, null);

다음 코드가 오류 없이 타입 체커를 통과할 수 있을지 생각해 보면, 설정이 어떻게 되어 있는지 모른다면 대답할 수 없는 질문입니다.

noImplicitAny

//tsconfig.json

{
  "compilerOptions":{
    "noImplicitAny": true
  }
}



// tsConfig: {"noImplicitAny":false}

function add(a, b) {
  return a + b;
}

noImplicitAny: false일때는 문제가 되지않는다. 위 코드는 noImplicitAny가 해제되어 있을 때에는 유효하다.
타입스크립트가 추론하여 함수의 타입을 any로 타입을 지정한다. **any는 유용하지만 매우 주의해서 사용해야 합니다.

// tsConfig: {"noImplicitAny":true}

function add(a: number, b: number) {
  return a + b;
}

같은 코드임에도 noImplicitAny: true 일때는 오류가 됩니다. 명시적으로 :any로 선언해 주거나 더 분명한 타입인 :number을 사용하면 해결할 수 있습니다.

타입스크립트는 타입 정보를 가질 때 가장 효과적이기 때문에, 되도록이면 noImplicitAny를 설정해야합니다. noImplicitAny 해제는 자바스크립트로 되어 있는 기존 프로젝트를 타입스크립트로 전환하는 상황에만 필요합니다.





strictNullChecks

strictNullChecks는 null과 undefined가 모든 타입에서 허용되는지 확인하는 설정입니다.

// tsConfig: {"strictNullChecks":false}

const x: number = null; // 정상, null은 유효한 값입니다.

"strictNullChecks":false 일때는 유효한 코드입니다.

// tsConfig: {"strictNullChecks":true}

const x: number = null; // null형식은 number형식에 할당할 수 없습니다.

"strictNullChecks":true 로 설정하면 오류가 됩니다. null대신 undefined를 써도 같은 오류가 나타납니다.
만약 null을 허용하려고 한다면, 아래 코드와 같이 의도를 명시적으로 드러냄으로써 오류를 고칠 수 있습니다.

// tsConfig: {"strictNullChecks":true}

const x: number | null = null; // null형식은 number형식에 할당할 수 없습니다.

strictNullChecks는 null과 undefined 관련된 오류를 잡아 내는 데 많은 도움이 되지만, 코드 작성을 어렵게 합니다. 새 프로젝트를 시작한다면 가급적 strictNullChecks를 설정하는 것이 좋지만, 타입스크립트가 처음이거나 자바스크립트 코드를 마이그레이션하는 중이라면 설정하지 않아도 괜찮습니다.

strictNullChecks를 설정하려면 noImplicitAny를 먼저 설정해야 합니다. 언어에 의미적으로 영향을 미치는 설정들이 많지만, noImplicitAny와 strictNullChecks만큼 중요한 것은 없습니다. 이 모든 체크를 설정하고 싶다면 strict 설정을 하면 됩니다. 타입스크립트에 strict 설정을 하면 대부분의 오류를 잡아냅니다.


요약

  • 타입스크립트 설정은 커멘드 라인을 이용하기보다는 tsconfig.json을 사용하는 것이 좋습니다.
  • 자바스크립트 프로젝트를 타입스크립트로 전환하는게 아닐라면 noImplicitAny를 설정하는 것이 좋습니다.
  • ‘undefined는 객체가 아닙니다’같은 런타임 오류를 방지하기 위해 strictNulllChecks를 설정하는 것이 좋습니다.
  • 타입스크립트에서 엄격한 체크를 하고 싶다면 strict설정을 고려해야 합니다.





아이템 3 코드 생성과 타입이 관계없음을 이해하기

타입스크립트 컴파일러는 두 가지 역할을 수행합니다.

  • 최신 타입스크립트/자바스크립트를 브라우저에서 동작할 수 있도록 구버전의 자바스크립트로 트랜스파일합니다.
  • 코드의 타입 오류를 체크합니다.

타입스크립트가 자바스크립트로 변환될 때 코드 내의 타입에는 영향을 주지 않습니다. 또한 그 자바스크립트의 실행 시점에도 타입은 영향을 미치지 않습니다.

  • 타입 오류가 있는 코드도 컴파일이 가능합니다.
  • 런타임에는 타입체크가 불가능합니다.
  • 타입 연산은 런타임에 영향을 주지 않습니다.
  • 런타임 타입은 선언된 타입과 다를 수 있습니다.
  • 타입스크립트 타입으로는 함수를 오버로드할 수 없습니다.
  • 타입스크립트 타입은 런타임 성능에 영향을 주지 않습니다.



타입 오류가 있는 코드도 컴파일이 가능합니다.

컴파일은 타입 체크와 독립적으로 동작하기 때문에, 타입 오류가 있는 코드도 컴파일이 가능합니다.

// test.ts

let x = 'hello';
x = 1234; //'1234'형식은 'string'형식에 할당할 수 없습니다.
// test.js

let x = 'hello';
x = 1234;

타입스크립트 오류는 C나 자바같은 언어들의 경고(warning)와 비슷합니다. 문제가 될만한 부분을 알려주지만, 그렇다고 빌드를 멈추지는 않습니다.

오류가 있을 때 컴파일러 결과파일을 생성하지않으려면,

tsconfig.jsonnoEmitInError를 설정하거나 빌드 도구에 동일하게 적용하면 됩니다.



런타임에는 타입체크가 불가능합니다.

타입스크립트의 타입은 ‘제거 가능(erasable)‘합니다. 실제로 자바스크립트로 컴파일되는 과정에서 모든 인터페이스, 타입, 타입 구문은 제거됩니다.

런타임에 타입정보를 유지하는 방법

  • 속성이 존재하는 지 체크
  • 명시적으로 저장하는 ‘태그’기법
  • 타입을 class로 만들어 타입(런타임 접근 불가)과 값(런타임 접근 가능)을 둘다 사용하는 기법



타입 연산은 런타임에 영향을 주지 않습니다.

string 또는 number 타입인 값을 항상 number로 정제하는 경우를 가정해 보겠습니다. 다음 코드는 타입 체커를 통과하지만 잘못된 방법을 썼습니다.

function asNumber(val: number | string): number {
  return val as number;
}

변환된 자바스크립트 코드를 보면 이 함수가 실제로 어떻게 동작하는지 알 수 있습니다.

function asNumber(val){
  return val;
}



런타임 타입은 선언된 타입과 다를 수 있습니다.

interface LightApiResponse {
  lightSwitchValue: boolean;
}
async function setLight() {
  const response = await fetch('/light');
  const result: LightApiResponse = await response.json();
  setLightSwitch(result.lightSwitchValue);
}

/light를 요청하면 그 결과로 LightApiResponse를 반환하라고 선언했지만, 실제로 그렇게 되리라는 보장은 없습니다. API를 잘못 파악해서 lightSwitchValue가 실제로는 문자열이었다면, 런타임에는 setLightSwitch함수까지 전달될 것 입니다. 또는 배포된 후에 API가 변경되어 lightSwitchValue가 문자열이 되는 경우도 있습니다.

타입스크립트에서는 런타임 타입과 선언된 타입이 맞지 않을 수 있습니다. 선언된 타입이 언제든지 달라질 수 있다는 것을 명심해야 합니다.



타입스크립트 타입으로는 함수를 오버로드할 수 없습니다.

function add(a: number, b: number) { return a + b; }
      // ~~~ 중복된 함수 구현입니다
function add(a: string, b: string) { return a + b; }
      // ~~~ 중복된 함수 구현입니다

동일한 이름에 매개변수만 다른 여러버전의 함수를 허용하는 것을 ‘함수 오버로딩’이라고 합니다.

타입스크립트에서는 타입과 런타임의 동작이 무관하기 때문에, 함수 오버로딩은 불가능합니다. 타입스크립트가 함수 오버로딩 기능을 지원하기는 하지만, 온전히 타입 수준에서만 동작합니다. 하나의 함수에 대해 여러 개의 선언문을 작성할 수 있지만, 구현체(implementation)는 오직 하나뿐입니다.



타입스크립트 타입은 런타임 성능에 영향을 주지 않습니다.

타입과 타입 연산자는 자바스크립트 변환 시점에 제거되기 때문에, 런타임의 성능에 아무런 영향을 주지 않습니다.

  • ‘런타임’오버헤드가 없는 대신, 타입스크립트 컴파일러는 ‘빌드타임’오버헤드가 있습니다.
  • 호환성과 성능사이의 선택은 컴파일 타깃과 언어 레벨의 문제이며 여전히 타입과는 무관합니다.



요약

  • 코드 생성은 타입 시스템과 무관합니다. 타입스크립트 타입은 런타임 동작이나 성능에 영향을 주지 않습니다.
  • 타입 오류가 존재하더라도 코드 생성(컴파일)은 가능합니다.
  • 타입스크립트 타입은 런타임에 사용할 수 없습니다. 런타임에 타입을 지정하려면, 타입 정보 유지를 위한 별도의 방법이 필요합니다.
    일반적으로는 태그된 유니온과 속성 체크 방법을 사용합니다. 또는 클래스 같이 타입스크립트 타입과 런타임 값, 둘 다 제공하는 방법이 있습니다.





아이템 4 구조적 타이핑에 익숙해지기

@woogie.kim 가 정리한 내용 🙏 이해에 도움을 줘서 감사합니다 😂

참고

용어 정리

덕 타이핑(duck typing)

객체가 어떤 타입에 부함하는 변수와 메소드를 가질 경우 객체를 해당 타입에 속하는 것으로 간주하는 방식 자바스크립트가 이에 해당한다.

덕 테스트(The Duck Test)에서 유래되었다. “만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다.”

그렇다면 해당 특성을 구현한 기계 오리는 오리? 이는 귀추법의 오류

const letDuckActive = (duck) => {
  duck.walk();
  duck.swim();
  duck.quack();
};

const duck = {
  walk: () => console.log('walk'),
  swim: () => console.log('swim'),
  quack: () => console.log('quack'),
}

letDuckActive(duck);

const robotDuck = {
  walk: () => console.log('w-a-l-k'),
  swim: () => console.log('s-w-i-m'),
  quack: () => console.log('q-u-a-ck'),
}

letDuckActive(robotDuck);

구조적 타이핑(structural typing)

구조적 타입 시스템(Structural Type System) 이라고도 불리며 실제 구조와 정의에 의해 결정되는 타입 시스템의 한 종류이다. 타입스크립트가 이에 해당한다.

명시적 선언이나 이름을 기반으로 하는 명목적 타입 시스템(Nominal Type System) 인 Java, C#등과 다르고 런타임에 타입을 체크하는 덕 타이핑(duck typing)과 다르다.

interface Vector2D {
  x: number;
  y: number;
}

const calculateLength = (v: Vector2D): number => {
  return Math.sqrt(v.x * v.y + v.y * v.y);
};

interface NamedVector {
  name: string;
  x: number;
  y: number;
}

const namedV: NamedVector = {
  name: "somewhere",
  x: 2,
  y: 3,
};

/**
 * Vector2D와 NamedVector의 관계를 별도로 선언하지 않아도 동작
 * 타입스크립트 타입 시스템은 자바스크립트의 런타임 동작을 모델링
 * NamedVector의 구조가 Vector2D와 호환되기 때문에 정상 호출 가능
 */
calculateLength(namedV);

위 예제는 명목적 타입 시스템을 기준으로 Vector2D를 사용하도록 의도한 코드 하지만 구조적 타이핑 언어에서는 전혀 다른 개념으로 이해해야 한다. Vector2D의 타입이 파라미터에 존재하는가를 체크





아이템 5 any 타입 지양하기

일부 특별한 경우를 제외하고는 any를 사용하면 타입스크립트의 수많은 장점을 누릴 수 없게 됩니다. 부득이하게 any를 사용하더라도 그 위험성을 알고 있어야 합니다

let age: number;
age = '12';
  // ~~~ Type '"12"' is not assignable to type 'number'
let age: number;
age = '12' as any;  // OK



any 타입에는 타입 안전성이 없습니다

number 타입으로 선언되었지만 as any를 사용하면 string 타입을 할당 할 수 있게 됩니다. 타입체커는 선언에 따라 number타입으로 판단할 것이고 혼돈은 걷잡을 수 없게 됩니다.

age += 1; // 런타임은 정상적으로 돌지만 원하는 값이 아닌 121로 나온다.



any는 함수 시그니처를 무시해 버립니다.

함수를 작성할 때는 시그니처를 명시해야 합니다. 호출하는 쪽은 약속된 타입의 입력을 제공하고, 함수는 약속된 타입의 출력을 반환하는데 any 타입을 사용하면 이런 약속을 어길 수 있습니다.

function calculateAge(birthDate: Date): number {
  // COMPRESS
  return 0;
  // END
}

let birthDate: any = '1990-01-19';
calculateAge(birthDate);  // OK

birthData 매개변수는 string이 아닌 Date 타입이어야 합니다. any 타입을 사용하면 calculateAge의 시그니처를 무시하게 됩니다.
string 타입은 number타입이 필요한 곳에서 오류 없이 실행될 때가 있고, 그럴 경우 다른 곳에서 문제를 일으키게 됩니다.



any 타입에는 언어 서비스가 적용되지 않습니다

어떤 심벌에 타입이 있다면 타입스크립트 언어 서비스는 자동완성 기능과 적절한 도움말을 제공합니다. 하지만 any타입인 심벌을 사용하면 아무런 도움을 받지 못합니다.

interface Person {
    first: string;
    last: string;
  }
  
  const formatName = (p: Person) => `${p.first} ${p.last}`;
  const formatNameAny = (p: any) => `${p.first} ${p.last}`;

편집기에서 first를 선택하고 ‘Rename Symbol’을 클릭해서 firstName으로 변경을 하면

interface Person {
    firstName: string;
    last: string;
  }
  
  const formatName = (p: Person) => `${p.firstName} ${p.last}`;  //자동 변경이 된다.
  const formatNameAny = (p: any) => `${p.first} ${p.last}`;  //변경이 되지않고 first로 남아있는다

any타입의 심벌은 바뀌지 않는 것을 확인할 수 있습니다.



any 타입은 코드 리팩터링 때 버그를 감춥니다

타입 체커를 통과함에도 불구하고 런타임에는 오류가 발생할 겁니다. any가 아니라 구체적인 타입을 사용했다면, 타입 체커가 오류를 발견했을 겁니다.

interface ComponentProps {
  onSelectItem: (item: any) => void;
}

// interface ComponentProps {
//   onSelectItem: (id: number) => void;
// }
function renderSelector(props: ComponentProps) { 
  /* ... */ 
}

let selectedId: number = 0;
function handleSelectItem(item: any) {
// function handleSelectItem(item: {id: number}) { // 객체
// function handleSelectItem(item: number) {
  selectedId = item.id;
  // selectedId = item;
}

renderSelector({onSelectItem: handleSelectItem});



any는 타입 설계를 감춰버립니다

상태 객체 안에 있는 수많은 속성의 타입을 일일이 작성해야 하는데, any타입을 사용하면 간단히 끝내버릴 수 있습니다. 물론 이때도 any를 사용하면 안됩니다. 객체를 정의할 때 특히 문제가 되는데, any를 사용하면 상태 객체의 설계를 감춰버리기 때문입니다. 깔끔하고 정확하고 명료한 코드작성을 위해 제대로 된 타입 설계는 필수입니다. any 타입을 사용하면 타입 설계가 불분명해집니다. 설계가 명확히 보이도록 타입을 일일이 작성하는 것이 좋습니다.



any는 타입시스템의 신뢰도를 떨어트립니다

런타임에 타입 오류를 발견하게 된다면 타입 체커를 신뢰할 수 없을 겁니다. any타입을 쓰지 않으면 런타임에 발견될 오류를 미리 잡을 수 있고 신뢰도를 높일 수 있습니다.

어쩔 수 없이 any를 써야만 하는 상황도 있습니다. 이럴 때 고민해 볼 수 있는 좋은 방법과 좋지 못한 방법이 있습니다. any의 단점을 어떻게 보완해야 하는지는 5장에서 자세히 다룹니다.



요약

  • any타입을 사용하면 타입 체커와 타입스크립트 언어 서비스를 무력화시킨다
  • any 타입은 진짜 문제점을 감춘다
  • 타입 시스템의 신뢰도를 떨어트린다
  • 최대한 사용을 피하도록 하자

디지엠유닛원 주식회사

  • 대표이사 권혁태
  • 개인정보보호책임자 정경영
  • 사업자등록번호 252-86-01619
  • 주소
    서울특별시 금천구 가산디지털1로 83, 6층 601호(가산동, 파트너스타워)
  • 이메일 unit1@dgmit.com