이펙티브 타입스크립트 2장 - 1

정태호·2023년 8월 2일
0

타입스크립트

목록 보기
6/13
post-thumbnail

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

타입스크립트를 설치하면, 다음 두 가지를 실행할 수 있다.

  • 타입스크립트 컴파일러(tsc)
  • 단독으로 실행할 수 있는 타입스크립트 서버(tsserver)

보통은 타입스크립트 컴파일러를 실행하는 것이 주된 목적이지만, 타입스크립트 서버 또한 언어 서비스를 제공한다.

언어 서비스

  • 코드 자동 완성, 명세(사양, specification)검사, 검색, 리팩터링

보통은 편집기를 통해서 언어 서비스를 사용하는데, 타입스크립트 서버에서 언어 서비스를 제공하도록 설정하는 게 좋다.

요약

  • 편집기에서는 타입스크립트가 언제 추론을 수행하는지 직접 확인할 수 있기 때문에 타입 시스템에 대한 개념을 쌓기에 좋다.
  • 타입스크립트가 동작을 어떻게 모델링하는지 알기 위해 타입 선언 파일을 찾아보는 방법을 터득하자!

아이템 7. 타입이 값들의 집합이라고 생각하기

ts 용어집합 용어
never공집합
리터럴 타입원소가 1개인 집합
값이 T에 할당 가능값이 T의 원소
T1이 T2에 할당 가능T1이 T2의 부분집합
T1이 T2를 상속T1이 T2의 부분집합
T1T2
T1 & T2T1과 T2의 교집합
unknown전체집합

타입을 값의 집합으로 생각하면 이해가 편하다.(타입의 범위). 이 집합은 유한(boolean 또는 리터럴 타입)하거나 무한(number 또는 string)하다.

다양한 타입스크립트 오류에서 할당 가능한이라는 문구를 볼 수 있는데 집합의 관점에서 ~의 원소(값과 타입의 관계) 또는 ~의 부분 집합(두 타입의 관계) 을 의미한다.

type AB = 'A' | 'B'
type AB12 = 'A' | 'B' | 12;
const a: AB = 'A'
const c: AB = 'C' // '"C"' 형식은 'AB' 형식에 할당할 수 없습니다.

타입 체커

  • 집합의 관점에서, 타입 체커의 주요 역할은 하나의 집합이 다른 집합의 부분 집합인지 검사하는 것!
interface Person {
  name: string;
}
interface LifeSpan {
  birth: Date;
  death?: Date;
}
type PersonSpan = Person & LifeSpan;

위의 예시에서 두 인터페이스는 공통으로 가지는 속성이 없기 때문에, PersonSpan 타입을 공집합(never)로 예상하기 쉽다. 하지만 타입 연산자는 인터페이스의 속성이 아닌, 값의 집합(타입의 범위)에 적용된다.

추가적인 속성을 가지는 값도 여전히 그 타입에 속하게 되고 Person과 Lifespan을 둘 다 가지는 값은 인터섹션 타입에 속하게 된다.

인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함하는 것이 일반적인 규칙이다.

두 인터페이스의 유니온에서는 아니다?

type K =  keyof (Person | LifeSpan) // 타입이 never

위 예제에서 유니온 타입에 속하는 값은 어떠한 키도 없기 때문에, 유니온에 대한 keyof는 공집합이다.

keyof (A&B) = (keyof A) | (keyof B)
keyof (A|B) = (keyof A) & (keyof B)

유니온 |

유니온은 합집합이다. 타입의 합집합은 더 넓은 값의 범위를 가진다는 의미이다.

type C = A | B ;

C는 A도 포함하고 B도 포함한다. 즉 A도 C를 만족하며 B도 C를 만족한다.

interface A {
  name: string;
}
interface B {
  age: number;
}

type C = A | B;

const c: C = {
  name: "hee",
};

만약 A한테만 name 프로퍼티가 있다면 C 타입을 매개변수로 받아 name 프로퍼티에 접근하려 할 때 B 에는 name이 없다는 오류가 발생할 것이다. 즉 C타입이 A타입보다 범위가 넓어졌기 때문에 발생한 오류다.

인터섹션 &

인터섹션은 교집합이다. 타입의 교집합은 값의 범위가 더욱 좁아진다는 의미이다.

interface A {
  name: string;
}
interface B {
  age: number;
}

type C = A & B;

const c: C = {
  name: 'hee',
  age: 25
}

타입 C는 A도 만족해야하고 B도 만족해야한다. 즉 C 의 값의 범위는 훨씬 좁아졌다. 만약 age가 없거나 name이 없다면 타입C 를 만족시키지 못한다.


일반적으로 PersonSpan 타입을 선언하는 방법은 extends 키워드를 사용하는 것이다.

타입이 집합이라는 관점에서 extends의 의미는 '~에 할당 가능한'과 비슷하게 '~의 부분 집합' 이라는 의미로 받아들일 수 있다.

요약

  • 타입을 값의 집합으로 생각하면 이해하기 수월하다.
  • 타입스크립트의 타입은 엄격한 상속 관계가 아니라 겹쳐지는 집합(벤 다이어그램)으로 표현된다. 두 타입은 서로 서브타입이 아니면서도 겹쳐질 수 있다.
  • 타입 연산은 집합 범위에 적용된다. A와 B의 인터섹션은 A의 범위와 B의 범위의 인터섹션이다. 객체 타입에서는 A & B 인 값이 A와 B의 속성을 모두 가짐을 의미.
  • A는 B를 상속, A는 B에 할당 가능, A는 B에 서브타입A는 B의 부분 집합 과 같은 의미이다.

아이템 8. 타입 공간과 값 공간의 심벌 구분하기

  • 심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타낼 수 있기 때문에 혼란스러울 수 있다.
interface Square {
  width: number;
  height: number
}

const Square = (width: number, height: number) => ({width, height})

위 코드에서 Square는 이름은 같지만 타입과 값으로 쓰였다. 이런 점이 가끔 오류를 야기한다.

function calculateVolume(shape: unknown) {
  if (shape instanceof Square) {
    // instanceof는 타입이 아닌 함수를 참조한다.
    shape.width; // '{}' 형식에 'width' 속성이 없습니다.
  }
}

많은 타입 코드가 값 코드와 비슷해 보이기 때문에 언뜻 봐서는 알아보기 힘들다. 두 공간에 대한 개념을 잡기위해 타입스크립트 플레이그라운드를 활용해보자!

타입과 값 구분하기

  • 타입 선언(:), 단언문(as) 다음에 나오는 심볼은 타입
  • =다음에 나오는 모든 것은 값
  • class, enum은 상황에 따라 타입과 값 둘 다 가능한 예약어

typeof

타입에서 쓰일 때와 값에서 쓰일 때 다른 기능을 하는 연산자

type T1 = typeof p; // 타입은 Person
type T2 = typeof email;
	// (p: Person, subject: string, body: string) => Response;

const v1 = typeof p; // 값은 Object
const v2 = typeof email; // 값은 function

타입 관점 : 값을 읽어 타입스크립트의 타입을 반환
값의 관점 : 자바스크립트 런타임 타입을 가리키는 문자열 반환

값과 타입 두 가지로 모두 사용하는 class는?

타입 관점 : 인스턴스 타입이 아닌, 생성자 함수
값의 관점 : function

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

const v = typeof Cylinder; // 값이 function
type T = typeof Cylinder; // 타입이 class Cylinder, 즉 생성자 함수

const c = new fn(); // 타입이 Cylinder, 즉 인스턴스

만약 클래스의 인스턴스를 타입으로 사용하고 싶다면 다음과 같이 InstanceType 제너릭을 작성하면 된다.

type C = InstanceType<typeof Cylinder>; // 타입이 Cylinder

두 공간 사이 다른 의미를 가지는 코드 패턴들

  1. this
  • 값 : 자바스크립트 this 키워드
  • 타입 : 다형성 this라고 불리는 타입스크립트의 타입
  1. &, |
  • 값 : AND와 OR 비트연산
  • 타입 : 인터섹션과 유니온
  1. const
  • 값 : 새 변수 선언
  • 타입 : as const -> 리터럴 또는 리터럴 표현식의 추론된 타입을 바꿈
  1. extends
  • 서브클래스(class A extends B)
  • 서브타입(interface A extends B)
  • 제너릭 타입의 한정자(Generic<T extends number>)
  1. in
  • 루프(for(ket in object)) 또는 매핑된 타입에 등장한다.

요약

  • 타입스크립트 코드를 읽을 때 타입인지 값이지 구분하는 방법을 터득하자!
  • 모든 값은 타입을 가지지만, 타입은 값을 가지지 않는다.

아이템 9. 타입 단언보다는 타입 선언을 사용하기

interface Person{
  name: string
}

const alice: Person = {name: 'Alice'}
const bob = {name: 'Bob'} as Person

첫 번째 alice 변수에 타입 선언을 붙여서 그 값이 선언된 타입임을 명시한다. 두 번째 as Person은 타입 단언을 수행한다. 이렇게 된다면 타입스크립트가 추론한 타입이 있더라도 Person 타입으로 간주해버린다.

타입 단언이 꼭 필요한 경우가 아니라면, 안전성 체크도 되는 타입 선언을 사용하자!

화살표 함수 타입 선언

  • 화살표 함수로 추론된 타입이 모호한 경우를 대응하기 위해 타입 선언을 해주어야 한다.
  • 타입 단언을 사용하는 경우엔 런타임에서 문제가 발생할 수 있으므로 지양한다.
const pepole = ['AA', 'BB', 'CC'].map(name => ({name}));
	// Person [] 이 아닌 { name: string; }[] ...

/* 타입 단언 */
const pepole = ['AA', 'BB', 'CC'].map(name => ({name} as Person));
	// Person []
const pepole = ['AA', 'BB', 'CC'].map(name => ({} as Person));
	// 런타임 에러, Person []

/* 타입 선언 */
// 화살표 함수가 Person을 반환할 것이라고 타입 선언
const pepole = ['AA', 'BB', 'CC'].map((name): Person => ({name}));
	// Person []

타입 단언이 필요한 경우

타입 체커가 추론한 타입보다 우리가 판단하는 타입이 더 정확할 때 의미가 있다.

  • DOM을 조작하는 경우
  • ! 문법을 사용해 null이 아님을 단언하는 경우
const elNull = document.getElementById('foo') // HTMLElement | null
const el = document.getElementById('foo')! // HTMLElement

주의할 점

타입 단언문으로 임의의 타입 간에 변환을 할 수는 없다. A가 B의 부분 집합인 경우에 타입 단언문을 사용해 변환할 수 있다.

interface Person {name: string}
const body = document.body
const el = body as Person;
// 'HTMLElement' 형식을 'Person' 형식으로 변환한 작업은 실수일 수 있습니다.
// 두 형식이 서로 충분히 겹치지 않기 때문입니다. 
//  의도적으로 변환한 경우에는 먼저 'unknown'으로 식을 변환합니다.

모든 타입은 unknown의 서브타입이기 때문에 unknown이 포함된 단언문은 항상 동작하지만 사용한 이상 무언가 위험한 동작을 하고 있다는 걸 명심하자.

아이템 10. 객체 래퍼 타입 피하기

  • 타입스크립트는 기본형과 객체 래퍼 타입을 별도로 모델링한다.
기본형객체 래퍼 타입
stringString
numberNumber
booleanBoolean
symbolSymbol
bigintBigint

요약

  • 기본형 값에 메서드를 제공하기 위해 객체 래퍼 타입이 어떻게 쓰이는지 이해해야 한다. 직접 사용하거나 인스턴스를 생성하는 것은 피하자.
function getStringLen(foo: String){
  return foo.length
}

getStringLen('hello') // 정상
getStringLen(new String('hello')) // 정상

function isGreeting(phrase: String){
  return [
    'hello',
    'good day'
  ].includes(phrase) // Error
}
// 'String' 형식의 인수는 'string' 형식의 매개 변수에 할당될 수 없습니다.
// string'은(는) 기본 개체이지만 'String'은(는) 래퍼 개체입니다. 가능한 경우 'string'을(를) 사용하세요.
  • string을 사용할 때 특히 유의해야 하는데 string은 String에 할당할 수 있지만 String을 string에 할당할 수 없다.
  • 타입스크립트 객체 래퍼 타입은 지양하고, 대신 기본형 타입을 사용해야 한다.

아이템 11. 잉여 속성 체크의 한계 인지하기

잉여 속성 체크는 타입이 명시된 변수에 객체 리터럴을 할당할 때, 해당 타입의 속성이 있는지 그리고 '그 외 속성은 없는지' 확인하는 것을 말한다.

잉여 속성 체크를 이용하면 기본적으로 타입 시스템의 구조적 본질을 해치지 않으면서도 객체 리터럴에 알 수 없는 속성을 허용하지 않는다.

interface Room {
  numDoors: number;
  ceilingHeightFt: number;
}

const r: Room = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: "present", 
  // 개체 리터럴은 알려진 속성만 지정할 수 있으며 'Room' 형식에 'elephant'이(가) 없습니다.
};

Room 타입에 elephant 속성이 있는 것이 어색하긴 하지만, 구조적 타이핑 관점으로 볼 때 오류가 발생하지 않아야 한다.

const obj = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: "present",
};

const rr: Room = obj; // 정상

위 코드에서 obj의 타입은 { numDoors: number; ceilingHeightFt: number; elephant: string;} 로 추론되며 obj 타입은 Room 타입의 부분 집합을 포함하므로, 할당 가능하며 타입 체커도 통과한다.

두 예제의 차이점을 보면 첫 번째에서는 구조적 타입시스템에서 발생할 수 있는 중요한 오류를 잡을 수 있도록 잉여 속성 체크 과정이 수행되었다.

잉여 속성 체크 역시 조건에 따라 동작하지 않는다는 한계가 있고, 통상적인 할당 가능 검사와 함께 쓰이면 구조적 타이핑이 무엇인지 혼란스러워 질 수 있다.

잉여 속성 체크를 안하는 경우

객체 리터럴 할당의 제외하고 잉여 속성 체크를 하지 않는다.

interface Options {
  title: string;
  darkMode?: boolean;
}

const o: Options = {darkmode: true, title: "Ski Free"} // darkMode로 써야함

// 임시변수 도입
const intermediate = {darkmode: true, title: "Ski Free"}
const o: Options = intermediate // 정상

이 예시에서 첫 번째 줄의 오른쪽은 객체 리터럴이지만, 두 번째 줄의 오른쪽은 객체 리터럴이 아니다. 따라서 잉여 속성 체크가 적용되지 않고 오류가 사라진다.

타입 단언문을 사용한다면 적용되지 않는다.

인덱스 시그니처를 사용해서 추가적인 속성을 예상하도록 할 수 있다.

interface Options {
  darkMode?: boolean;
  [otherOptions: string]: unknown;
}

const o: Options = {darkMode: true} // 정상

공통 속성 체크

  • 타입과 선언 타입에 공통된 속성이 있는지 확인하는 별도의 체크
  • 선택적 속성만 가지는 '약한(weak)'타입에서는 공통 속성 체크가 동작한다.
interface LineChartOptions {
    logscale?: boolean;
    invertedYAxis?: boolean;
    areaChart?: boolean;
}

const opts = { logScale: true };
const o: LineChartOptions = opts;
// '{ logScale: boolean; }' 유형에 'LineChartOptions' 유형과 공통적인 속성이 없습니다.

구조적 타이핑 관점에서는 LineChartOptions 타입은 모든 속성이 선택적이므로 모든 객체를 포함할 수 있다.

하지만 이런 약한 타입에 대해서는 공통 속성 체크를 수행한다.

공통 속성 체크잉여 속성 체크와 마찬가지로 오타를 잡는 데 효과적이며 구조적으로 엄격하지 않다. 그러나 잉여 속성 체크와 다르게, 약한 타입과 관련된 할당문 마다 수행되며 임시 변수를 제거하더라도 공통 속성 체크는 여전히 동작한다.

요약

  • 객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 속성 체크가 수행된다.
  • 임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다는 한계가 있기 때문에 이 점을 기억하자.
profile
주니어 프론트엔드 개발자가 되고 싶습니다!

1개의 댓글

comment-user-thumbnail
2023년 8월 2일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

답글 달기