큰 꿈은 파편이 크다!!⚡️

이펙티브 타입스크립트 🦅 2장 (1) 본문

Web FE

이펙티브 타입스크립트 🦅 2장 (1)

wood.forest 2022. 8. 28. 17:31

2장 타입스크립트의 타입 시스템 (1)편에 들어가며

2장은 양이 생각보다 아주 매우 너무나 많아서 부득이하게 세 챕터로 나눠서 작성한다. (페이지 수가 1장의 거의 세 배)

 

 

 

6. 편집기를 사용하여 타입 시스템 탐색하기

TS를 설치하면 타입스크립트 컴파일러(tsc), 단독으로 실행할 수 있는 TS서버(tsserver)를 실행할 수 있다

- TS서버에서도 설정을 통해 편집기 자동완성, Go to Definition 등의 기능을 꼭 사용하자!

- 편집기에서 함수 또는 변수 위에 커서를 올리면 추론된 타입이 나타난다. 만약 기대한 타입과 다르다면 타입 선언을 직접 명시하자

 

 

 

 

7. 타입이 값들의 집합이라고 생각하기 🧐 이 아이템은 엄청 헷갈렸다..

- 코드가 실행되기 전, 즉 TS가 오류를 체크하는 순간에 변수는 타입을 가진다. 타입은 할당 가능한 값들의 집합 이며, 이 집합은 타입의 범위 라고도 부른다

- 가장 작은 집합은 아무 값도 포함하지 않는 공집합이며, TS에서는 never 타입이다

    - never 타입 변수는 공집합이므로 아무런 값도 할당할 수 없다

const x: never = 12;
   // ~ Type '12' is not assignable to type 'never'

- 그 다음으로 작은 집합은 한 가지 값만 포함하는 리터럴literal 타입이며, 유닛unit 타입이라고도 불린다

type A = 'A';
type B = 'B';
type Twelve = 12;

 

 

두 개 이상으로 묶으려면 유니온union 타입을 사용한다

- 유니온 타입은 값 집합들의 합집합이다

- 집합 관점에서 TS의 역할은 하나의 집합이 다른 집합의 부분 집합인지 검사하는 것이라고 볼 수 있다

type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;

type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;
const a: AB = 'A';  // OK, value 'A' is a member of the set {'A', 'B'}
const c: AB = 'C';
   // ~ Type '"C"' is not assignable to type 'AB'

 

 

& 연산자는 두 타입의 인터섹션(intersection, 교집합)을 계산한다

- PersonLifespan 인터페이스는 공통으로 가지는 속성이 없으므로 PersonSpan을 공집합(never 타입)으로 예상하기 쉽지만, 타입 연산자는 인터페이스의 속성이 아닌, 값의 집합(타입의 범위)에 적용되며 추가적인 속성을 가지는 값도 그 타입에 속한다

- 즉, PersonLifespan 둘 다 가지는 값은 인터섹션 타입에 속한다

- 인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함한다

interface Identified {
  id: string;
}
interface Person {
  name: string;
}
interface Lifespan {
  birth: Date;
  death?: Date;
}
type PersonSpan = Person & Lifespan;
const ps: PersonSpan = {
  name: 'Alan Turing',
  birth: new Date('1912/06/23'),
  death: new Date('1954/06/07'),
};  // OK

- 그러나 두 인터페이스의 유니온에서는 그렇지 않다

interface Identified {
  id: string;
}
interface Person {
  name: string;
}
interface Lifespan {
  birth: Date;
  death?: Date;
}
type PersonSpan = Person & Lifespan;
type K = keyof (Person | Lifespan);  // Type is never

 

 

extends를 통해 할당 가능한 부분 집합을 만들 수 있다

- Person을 상속한다는 의미를 집합의 관점에서 보면, PersonSpanPerson의 부분 집합 범위를 가지는 어떤 타입이 된다

interface Person {
  name: string;
}
interface PersonSpan extends Person {
  birth: Date;
  death?: Date;
}

 

 

 

 

 

8. 타입 공간과 값 공간의 심벌 구분

TS의 심벌Symbol은 타입 공간이나 값 공간 중의 한 곳에 존재하는데, 이름이 같더라도 속하는 공간에 따라 다른 것을 나타낼 수 있다

코드를 읽을 때 타입인지 값인지, 두 공간을 구분하려면 TS playground를 사용해보자. TS소스에서 변환된 JS결과물을 확인해보면 컴파일 과정에서 타입 정보는 제거되기 때문에, 심벌이 사라진다면 그것은 타입에 해당된다고 볼 수 있다.

예제

- interface Cylinder에서 Cylinder는 타입이지만, const Cylinder에서의 Cylinder는 값이다

    - 일반적으로 type/interface뒤에 나오는 심벌은 타입이고, const/let뒤에 나오는 심벌은 값이다

shape instanceof Cylinder를 통해 shapeCylinder 타입인지 체크하려고 했으나, instanceof는 JS의 런타임 연산자이고 값에 대해 연산하기 때문에 오류가 발생한다. instanceof Cylinder는 타입이 아닌 함수를 참조한다.

interface Cylinder {
  radius: number;
  height: number;
}

const Cylinder = (radius: number, height: number) => ({radius, height});
function calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) {
    shape.radius
       // ~~~~~~ Property 'radius' does not exist on type '{}'
  }
}

 

- 타입 선언(: Type) 또는 단언문(as Type) 뒤에 나오는 심벌은 타입이고, = 뒤에 나오는 모든 것은 값이다

- 클래스가 타입으로 쓰일 때는 형태(속성, 메소드)가 사용되는 반면, 값으로 쓰일 때는 생성자가 사용된다

예제

typeof 연산자는 타입에서 쓰일 때 TS 타입을 반환하지만 값의 관점에서는 JS런타임의 typeof 연산자가 되어 JS 런타임 타입을 반환한다

    - const v = typeof Cylinder 는 클래스가 JS에서는 실제 함수로 구현되기 때문에 function이다

    - type T = typeof Cylinder 에서 Cylinder는 인스턴스의 타입이 아닌, new키워드를 사용할 때 볼 수 있는 생성자 함수다.

 

class Cylinder {
  radius=1;
  height=1;
}

function calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) {
    shape  // OK, type is Cylinder
    shape.radius  // OK, type is number
  }
}
const v = typeof Cylinder;  // Value is "function"
type T = typeof Cylinder;  // Type is typeof Cylinder

 

 

타입 공간과 값 공간을 이해하지 못하고 작성한 TS코드는 잘 동작하지 않을 수 있다

예제  예제는 나도 평소 궁금했던 내용이다!

    1) 단일 객체 매개변수를 받는 email 함수가 있다

interface Person {
  first: string;
  last: string;
}
function email(options: {person: Person, subject: string, body: string}) {
  // ...
}

    2) 객체 내의 각 속성을 로컬 변수로 만들어 주는 구조 분해destructuring 할당을 TS에서 사용해보면, 오류가 발생한다

        - Person, string이 값의 관점에서 해석되기 때문이다

        - 값의 관점에서 해석하면, Personstring이라는 이름을 가지는 두 개의 변수를 생성하려고 시도하게 된다

function email({
  person: Person,
       // ~~~~~~ Binding element 'Person' implicitly has an 'any' type
  subject: string,
        // ~~~~~~ Duplicate identifier 'string'
        //        Binding element 'string' implicitly has an 'any' type
  body: string}
     // ~~~~~~ Duplicate identifier 'string'
     //        Binding element 'string' implicitly has an 'any' type
) { /* ... */ }

 

    3) 문제 해결을 위해 타입과 값을 구분하여 작성한다

function email(
  {person, subject, body}: {person: Person, subject: string, body: string}
) {
  // ...
}

 

 

- 모든 값은 타입을 가지지만, 타입은 값을 가지지 않는다.

    - typeinterface같은 키워드는 타입 공간에만 존재한다

- classenum 등, 많은 연산자들과 키워드들은 타입 공간과 값 공간에서 다른 목적으로 사용될 수 있다

 

 

 

 

 

9. 타입 단언보다는 타입 선언 사용하기

- : 은 변수에 타입 선언을 붙여서 값이 선언된 타입임을 명시한다

- as 는 TS가 추론한 타입이 있더라도 타입 단언한 타입으로 간주하게 한다

interface Person { name: string };

const alice: Person = { name: 'Alice' };  // Type is Person
const bob = { name: 'Bob' } as Person;  // Type is Person

- 타입 선언은 할당되는 값이 해당 인터페이스를 만족하는지 검사하여 오류를 표시하고, 타입 단언은 강제로 타입을 지정했으니 타입 체커에게 오류를 무시하라고 한다

- 타입 선언문에서는 잉여 속성 체크를 하지만, 단언문에서는 적용되지 않는다

interface Person { name: string };

const alice: Person = {};
   // ~~~~~ Property 'name' is missing in type '{}'
   //       but required in type 'Person'
const bob = {} as Person;  // No error

const alice2: Person = {
  name: 'Alice',
  occupation: 'TypeScript developer'
// ~~~~~~~~~ Object literal may only specify known properties
//           and 'occupation' does not exist in type 'Person'
};
const bob2 = {
  name: 'Bob',
  occupation: 'JavaScript developer'
} as Person;  // No error

 

- 화살표 함수의 타입 선언은 추론된 타입이 모호할 때가 있으므로 반환 타입을 잘 작성하자

interface Person { name: string };
const people: Person[] = ['alice', 'bob', 'jan'].map(
  (name): Person => ({name})
);

 

 

타입 단언이 꼭 필요한 경우

- 타입 체커가 추론한 타입보다 개발자가 판단하는 타입이 더 정확할 때

- ! 를 사용해서 null이 아님을 단언하는 경우

    - 단언문은 컴파일 과정에서 제거되므로 null이 아님을 확신할 수 있을때 사용

    - 확신할 수 없다면 null인 경우를 체크하는 조건문 사용

const elNull = document.getElementById('foo');  // Type is HTMLElement | null
const el = document.getElementById('foo')!; // Type is HTMLElement

 

 

 

10. 객체 래퍼 타입 피하기

"primitive".charAt(3)

- JS 기본형 type들은 메서드를 가지지 않는데, 위 예제를 보면 그중 하나인 string은 메서드를 가진 것처럼 보인다. 사실 charAtstring의 메서드가 아니며 string '기본형'에는 메서드가 없는 것이 맞다. JS에는 String 객체 타입이 정의되어 있어 기본형과 객체 타입을 서로 자유롭게 변화한다. 위 경우 JS는 기본형을 String객체로 래핑wrap하고, 메서드를 호출한 뒤, 래핑한 객체를 버린다

- String.prototype몽키-패치monkey-patch한다면 동작을 확인할 수 있다

    - 메서드 내의 thisString 객체 래퍼다.

// 실제로 이렇게 하지 마세요!
const originalCharAt = String.prototype.charAt;
String.prototype.charAt = function(pos) {
  console.log(this, typeof this, pos);
  return originalCharAt.call(this, pos);
};
console.log('primitive'.charAt(3));

    - String객체를 직접 생성할 수도 있으며, string기본형처럼 동작하는 듯 하지만 항상 동일하게 동작하는 것은 아니다. String객체는 오직 자기 자신과 동일하므로 객체 래퍼를 직접 생성해서 사용하지 말자

"hello" === new String("hello") //false
new String("hello") === new String("hello") //true

 

 

TS는 기본형과 객체 래퍼 타입을 별도로 모델링한다

- string/String, number/Number, boolean/Boolean, symbol/Symbol, bigint/BigInt

- 런타임의 값은 객체가 아닌 기본형이며, 기본형은 객체 래퍼에 할당될 수 있지만 반대는 불가능하다. 따라서 const s:String 으로 사용하는 것이 가능하지만 이렇게 할 필요가 없으니 지양하자

- new 없이 BigInt, Symbol을 호출하는 경우에는 기본형을 생성하므로 Symbol("sym")과 같이 사용해도 좋다

 

 

 

 

마무리

이 글을 통해 새롭게 배운 내용은 이러하다.

- TS에서의 인터섹션과 유니온 원래 생각하던 개념과 반대로 생각..

- 타입 공간과 값 공간

- 타입 단언과 타입 선언, 그리고 언제 무엇을 사용해야 할지

- 객체 래퍼 타입과 동작 방식

낯설지만 TS를 작성하면서 한번쯤 궁금했던.. 그러나 굳이 찾아보지 않고 넘어간 내용들이 해소되었다. 

반응형