이펙티브 타입스크립트 | 2장 타입스크립트의 타입시스템

dev_hee·2022년 7월 26일
1

TypeScript

목록 보기
2/6

본 문은 이펙티브 타입스크립트를 읽고 알게된 내용을 정리한 글입니다.


[아이템6] 편집기를 사용해 타입 시스템 탐색하기

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

  1. 타입스크립트 컴파일러(tsc)
    타입스크립트 코드를 자바스크립트 코드로 변환해주는 도구이다.
 npx tsc {파일명}.ts

위의 명령어로 tsc 를 사용하여 타입스크립트를 자바스크립트 파일로 컴파일 할 수 있다.

npx tsc --strict {파일명}.ts

--strict 옵션으로 엄격한 기준으로 타입 검사를 수행할 수도 있다.

  1. 단독으로 실행할 수 있는 타입스크립트 서버(tsserver)

타입스크립트 서버는 타입스크립트 컴파일러와 언어 서비스 를 캡슐화한 실행가능한 노드이다.

타입스크립트 서버는 편집기와 IDE 와 함께 사용하기에 최적화되어있다.

언어 서비스

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

언어 서비스는 위의 기능들을 제공한다.
보통은 언어 서비스를 편집기 를 통해 사용하게 된다.
언어 서비스에서 제공하는 기능들은 매우 유용하므로 타입스크립트 서버에서 언어 서비스를 사용할 수 있도록 설정하는 것이 좋다.

그리고 편집기에서는 타입스크립트가 언제 타입 추론을 수행하는지 직접 확인할 수 있기 때문에 타입 시스템에 대한 개념을 쌓기에 매우 좋다.

언어서비스는 라이브러리와 그에 대한 타입 선언을 탐색할 때에 도움이 된다. 편집기는 Go to Definition(정의로 이동) 옵션을 제공해서 라이브러리가 어떻게 모델링되었는지 확인하기 편하다.

  • 편집기에서 타입스크립트 언어 서비스를 적극 활용해야 한다.
  • 편집기를 사용하면 어떻게 타입 시스템이 동작하는지, 타입을 어떻게 추론하는지 개념을 잡을 수 있다.
  • 타입스크립트가 동작을 어떻게 모델링하는지 알기 위해 타입 선언 파일을 찾아본다.

[아이템7] 타입은 값들의 집합이다

타입은 "할당 가능한 값들의 집합 또는 타입의 범위"이다.

타입의 종류

never

가장 작은 타입. 아무 값도 포함하지 않는 공집합이다.

유닛(unit)타입 / 리터럴(literal) 타입

never 다음으로 작은 타입. 한 가지 값만 포함한다.

type A = 'A';

유니온(union)타입

타입을 두 개 이상 묶은 타입이다.

type AB = 'A' | 'B';

타입 체커

집합의 관점에서 타입 체커는 하나의 집합이 다른 집합의 부분 집합인지 검사하는 역할을 가졌다.

const ab: AB = Math.random() < 0.5 ? 'A' : 'B'; // 정상
const back: AB = 1; // '1' 형식은 'AB' 형식에 할당할 수 없습니다.
  • 할당
    T1이 T2에 할당 가능하다. = T1이 T2의 부분집합이다.

  • 상속
    T1이 T2를 상속한다. = T1이 T2의 부분집합이다.

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

const ab: AB = Math.random() < 0.5 ? 'A' : 'B'; // 정상, {"A", "B"}는 {"A", "B"} 의 부분집합

const ab12: AB12 = ab; // 정상, {"A", "B"} 는 {"A", "B", 12} 의 부분집합

declare let twelve: AB12;
const back: AB = twelve;
// Type 'AB12' is not assignable to type 'AB'.
//   Type '12' is not assignable to type 'AB'.
// AB12 타입의 값은 AB 타입의 변수에 할당 불가능하다. 부분집합이 아니다.

구조적 타이핑 vs 잉여 속성 체크

  • 구조적 타이핑 규칙을 따른다면, 어떤 값이 다른 속성도 가질 수 있음을 의미한다. 심지어 함수의 매개변수에서도 다른 속성을 가질 수 있다.

  • 잉여 속성 체크: 특정 상황에서 추가 속성을 허용하지 않는 타입 체크이다.

속성에 대한 인터섹션(intersection, 교집합)

interface Person {
    name: string;
}

interface Lifespan {
    birth: Date;
    death?: Date;
}

type PersonSpan = Person & Lifespan;
// Person와 Lifespan가 공통적으로 가지는 속성이 없다.
// PersonSpan은 never 일까?

위의 예시에서 Person와 Lifespan가 공통적으로 가지는 속성이 없기 때문에 PersonSpan은 never 타입일 것 같다.

하지만 타입 연산자는 인터페이스 속성이 아닌, 값의 집합(타입의 범위) 에 적용된다.
따라서 구조적 타이핑에 의해 추가적인 속성을 가지는 값도 여전히 그 타입에 속하게 된다.

그래서 Person과 Lifespan을 둘 다 가지는 값은 인터섹션 타입에 속하게 된다.

const ps: PersonSpan = {
    name: 'Alan Turing',
    birth: new Date('1912/06/23'),
    death: new Date('1954/06/07'),
} // 정상
  • 값 ps 는 Person 타입에 속하는가? Yes, name 속성을 가지고 있기 때문에 다른 속성들을 가지고 있어도 구조적 타이핑에 의해 Person타입에 할당 가능하다.

  • 값 ps 는 Lifespan 타입에 속하는가? Yes, birth 속성을 가지고 있기 때문에 다른 속성들을 가지고 있어도 구조적 타이핑에 의해 Lifespan타입에 할당 가능하다.

  • 값 ps 는 PersonSpan 타입에 속하는가? Yes, Person과 Lifespan을 둘 다 가진다.

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

속성에 대한 유니온(Union, 합집합)

인터섹션 타입의 값(속성에 대한 인터섹션)은 각 타입 내의 속성을 모두 포함하는 것이 규칙이다.
하지만 두 인터페이스의 유니온(속성에 대한 유니온)에서는 그렇지 않다.

interface Person {
    name: string;
}

interface Lifespan {
    birth: Date;
    death?: Date;
}

type K = keyof (Person | Lifespan); // never

Person과 Lifespan의 합집합에 속하는 값은 어떠한 키도 없기 때문에, 유니온에 대한 keyof는 never이다.

  • Person | Lifespan{ name: string; } 을 할당 가능한가? YES, Person 에 포함되기 때문이다.

  • Person | Lifespan{ birth: Date; } 을 할당 가능한가? YES, Lifespan 에 포함되기 때문이다.

  • { name: string; }, { birth: Date; } 의 합집합은 name 을 가질 수도, 안가질 수도 있으며 birth 또한 그러하다. 따라서 Person | Lifespan에 속하는 값은 어떤 키를 가질지 알 수 없다.

keyof

type A = {
    a: string
}
type B = {
    b: string;
}

// keyof (A&B) 와 동일하다.
type AnB = (keyof A) | (keyof B); // "a" | "b" -> 각 속성을 모두 포함

// keyof (A|B) 와 동일하다.
type AuB = (keyof A) & (keyof B); // never

앞에서 설명한 유니온에 대한 keyof 과, 인터섹션에 대한 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를 만족한다.

만약 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 를 만족시키지 못한다.

서브타입 extends 키워드

서브타입은 어떤 집합의 부분집합이라는 뜻이다. 즉 값의 범위가 더욱 제한적이다.

extends 키워드로 클래스를 상속받은 서브클래스는 수퍼클래스보다 훨씬 좁은 범위의 값을 가지게 된다. extends 키워드는 집합의 부분집합을 만든다.

interface Vector1D { x: number; }
interface Vector2D extends Vector1D { y: number; }
interface Vector3D extends Vector2D { z: number; }

Vector2D는 Vector1D 의 서브타입이다.

따라서 Vector2D 타입의 값은 Vector1D 에 포함된다.

덕타입핑에 의해서 타입에 선언된 프로퍼티나 값의 범위만 만족시키면, 그 외의 다른 값들은 상관없이 동일한 타입이라고 보기 때문이다.

제네릭 타입에서 extends

function getKey<K extends string>(val: any, ket: K) {
    // ...
}

extends 키워드는 제네릭 타입에서 한정자로 쓰인다.
K extends string 에서 K는 string의 부분집합이다.

string을 상속한다는 의미는 "객체 상속 관점"에서 생각하면 어렵다.
하지만 "집합의 관점"으로 생각하면 쉽게 이해할 수 있다.
즉, K는 string의 부분 집합 범위를 가지는 어떤 타입이 된다.

getKey({}, 'x'); // 정상, 'x'는 string 을 상속
getKey({}, Math.random() < 0.5 ? 'a' : 'b'); // 정상, 'a' | 'b' 는 string을 상속
getKey({}, 12); 
    // Argument of type 'number' is not assignable to parameter of type 'string'.

타입스크립트 타입이 되지 못하는 값의 집합

  • 정수에 대한 타입
  • x와 y 속성 외에 다른 속성이 없는 객체

Exclude 를 사용하여 일부 타입을 제외하는 경우엔, 그 결과가 적절한 타입스크립트 타입일 때만 유효하다.

type T = Exclude<string|Date, string|number>; // 적절한 경우 - 타입이 Date가 됨
type NonZeroNums = Exclude<number, 0>; // 적절하지 못한 경우 - 타입은 여전히 number

keyof T

keyof는 객체의 키타입을 반환한다.

객체의 키값의 집합이다.

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

type PKey = keyof P; // "x" | "y"

용어 정리

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

[아이템8] 타입 공간과 값 공간 구별하기

타입스크립트에서 이름이 같더라도 속한 공간에 따라 다른 것(타입 또는 값)을 나타낼 수 있다.

즉 같은 이름의 타입과 식별자가 있으면 둘이 속한 공간이 다르게 된다.

interface A { // 타입
  b: number;
}

const A = 1; // 변수

위 예제는 오류가 발생하지 않는다.
타입 A와 값 A는 서로 아무런 관련이 없다.

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

const Cylinder = (radius: number, height: number) => ({ radius, height });

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

위의 예제도 마찬가지이다. shape instanceof Cylinder 에서 Cylinder 는 함수로 참조된다.

type vs const

type T1 = 'string literal';
const v1 = 'string literal';

위에서 type 으로 선언한 T1 은 타입, const로 선언한 v1 는 값이다.
자바스크립트 코드로 컴파일 하면 다음과 같다.

const v1 = 'string literal';

즉 타입은 컴파일 하는 과정에서 제거된다.

  • 타입
    type, interface

  • const, let, var
  • 둘 다
    class, 생성자함수, enum

class

  • 타입으로 쓰일 때 : 형태(속성과메서드)
  • 값으로 쓰일 때 : 생성자

class 는 타입과 값 두 가지 모두로 사용된다.
따라서 런타임 때 타입을 확인하고 싶다면 클래스를 사용해서 타입을 선언하면 된다.

typeof 연산자

  • 타입에서 쓰일 때 : 타입스크립트의 타입
  • 값에서 쓰일 때 : 자바스크립트 런타임 typeof 연산자
interface Person {
 name: string; 
}
const p: Person = { name: 'hee' };

type T = typeof p; // 타입은 Person
const v1 = typeof p; // 값은 'object'

클래스에서 typeof 를 사용한 경우는 다음과 같다.

  • 타입으로 쓰일 때 : 인스턴스 타입이 아닌, 생성자 함수
  • 값으로 쓰일 때 : '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

속성 접근자 []

속성 접근자 []은 값 또는 타입으로 사용할 때 동일하게 동작한다.

하지만 . 접근자는 타입의 속성을 얻을 때 사용할 수 없다.

따라서 타입의 속성을 얻을 때에는 반드시 [] 접근자를 사용해야 한다.

type Person = {
    first: string;
    age: number;
}
const pigme = {
    first: 'hee',
    age: 20,
}

const first: Person['first'] = pigme['first']; // 정상, string
const first2: Person.first = pigme['first']; // 오류, 
// Cannot access 'Person.first' because 'Person' is a type, but not a namespace. Did you mean to retrieve the type of the property 'first' in 'Person' with 'Person["first"]'?

타입과 값 공간 사이에서 다른 의미를 가지는 코드 패턴

  1. this
  • 값 : 자바스크립트의 this 키워드
  • 타입 : 다형성(polymoriphic) 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 in
  • 타입: 맵핑된 타입

[아이템9] 타입 단언보다는 타입 선언을 사용하자

타입을 부여하는 방법은 두 가지이다.

  • 타입 선언
    변수에 타입 선언을 붙여 그 값이 선언된 타입임을 명시한다.

  • 타입 단언
    타입스크립트가 추론한 타입이 있더라도 단언된 타입으로 간주한다.

interface Person { name: string };

const hee: Person = { name: 'hee' }; // 타입 선언
const jinu = { name: 'jinu' } as Person; // 타입 단언

타입 단언은 안전성이 체크되지 않으니, 더 안전한 타입 선언을 사용하자.

화살표 함수 타입 선언

화살표 함수로 추론된 타입이 모호한 경우를 대응하기 위해 타입 선언을 해주어야 한다.
타입 단언을 사용하는 경우엔 런타임에서 문제가 발생할 수 있으므로 지양한다.

const pepole = ['hee', 'jinu', 'ruby'].map(name => ({name}));
	// Person [] 이 아닌 { name: string; }[] ...

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

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

타입 단언이 필요한 경우

타입 체커가 추론한 타입 보다 개발자가 판단하는 타입이 더 정확할 때 타입 단언을 사용한다.

  • DOM 조작을 하는 경우
  • !. 단언문으로 null 이 아님을 단언함

전혀 관계없는 임의의 타입 간 변환은 불가능

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

PersonHTMLElement는 서로 서브타입이 아니기 때문에 변환이 불가능하다.

의도적으로 모든 타입의 상위 타입인 unknown 을 사용해서 임의의 타입 간의 변환을 가능하게 할 수 있지만 매우 위험하다.

const el = document.body as unknow as Person;

[아이템10] 래퍼 객체 타입을 피하자

원시값을 객체처럼 메서드를 사용할 수 있는 이유는 자바스크립트 엔진이 래퍼 객체로 일시적으로 원시값을 래핑하고 메서드를 호출하고 마지막엔 래퍼 객체를 버리기 때문이다.

타입스크립트는 원시값 타입과 래퍼 객체 타입을 별도로 모델링한다.

원시값 타입래퍼 객체 타입
numberNumber
stringString
booleanBoolean
symbolSymbol
bigintBigint

원시값 타입은 래퍼 객체에 할당하는 것을 허용하지만, 오해하기 싶고 굳이 그렇게 할 필요가 없다.

const s: String = "primitive"; // 정상이지만 안티 패턴

타입스크립트에서 래퍼 객체 타입은 지양하고 원시값 타입(기본형 타입)을 사용해야 한다.


[아이템11] 잉여 속성 체크의 한계

잉여 속성 체크

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

  • 잉여 속성 체크를 하는 예시
interface Person {
 name: string;
 age: number;
}

const hee: Person = {
  name: 'hee',
  age: 25,
  elephant: true, 
//'{ name: string; height: number; elephant: boolean; }' 형식은 'Person' 형식에 할당할 수 없습니다.
// 개체 리터럴은 알려진 속성만 지정할 수 있으며 'Person' 형식에 'elephant'이(가) 없습니다.
}

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

객체 리터럴 할당을 제외한 경우는 잉여 속성 체크를 하지 않는다.

다음 예제는 할당 가능 검사를 하는 경우이다. 이 경우엔 잉여 속성 체크를 하지 않는다. 즉 덕타이핑에 의해서 주어진 타입의 속성만 만족하면 된다.

const obj = {
  name: 'hee',
  age: 25,
  elephant: true, 
}
const p: Person = obj; // 정상

공통 속성 체크

값 타입과 선언 타입에 공통된 속성이 있는지 확인하는 별도의 체크

선택적 속성만 가지는 '약한(weak)'타입에서는 공통 속성 체크가 동작한다.

interface LineChartOptions {
    logscale?: boolean;
    invertedYAxis?: boolean;
    areaChart?: boolean;
}

const opts = { logScale: true };
const o: LineChartOptions = opts;
// Error: Type '{ logScale: boolean; }' has no properties in common with type 'LineChartOptions'.

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

하지만 이런 약한 타입에 대해서는 타입스크립트는 값 타입과 선언 타입에 공통된 속성이 있는지 확인하는 별도의 체크를 수행한다.

  • 잉여 속성 체크와 마찬가지로 오타를 잡는데 효과적이다.
  • 잉여 속성 체크는 객체 리터럴 할당시에만 동작하지만, 공통 속성 체크는 약한 타입과 관련된 할당문 마다 수행된다.

[아이템12] 함수 표현식에 타입 적용하기

다음은 함수 선언문과 함수 표현식이다.

/* 함수 선언문 */
function increase(num: number): number { /* */ }

/* 함수 표현식 */
const increase = function(num: number): number { /* */}
const increase = (num: number): number => { /* */}

타입스크립트에서는 함수 선언문 보다 함수 표현식을 사용하는 것이 좋다.

그 이유는 다음과 같다.

1. 함수의 매개변수 부터 반환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용할 수 있다.

type Add = (num: number) => number;
const add: Add = num => { /* ... */ }

2. 불필요한 타입 선언 코드의 반복을 줄인다.

type BinaryFn = (a: number, b: number) => number;
const add: BinaryFn = (a, b) => a + b;
const sub: BinaryFn = (a, b) => a - b;
const mul: BinaryFn = (a, b) => a * b;
const div: BinaryFn = (a, b) => a / b;

3. 공통 콜백 함수를 위한 타입 선언을 제공할 수 있다.

라이브러리는 공통 함수 시그니처를 타입으로 제공하여, 공통 콜백 함수를 위한 타입 선언을 제공한다.

리액트는 함수의 매개변수에 명시하는 MouseEvent 타입 대신에, 함수 전체에 적용할 수 있는 MouseEventHandler 타입을 제공한다.

만약 다른 함수의 시그니처를 참조하려면 typeof fn 을 사용하면 된다.


[아이템13] 타입과 인터페이스의 공통점과 차이점

타입스크립트에서 명명된 타입(named type)을 정의하는 typeinterface 가 있다.

공통점

1. 타입 상태에는 차이가 없다.

type TPerson = {
  name: string,
}
interface IPerson {
  name: string;
}
// 두 타입 모두 상태가 동일하다.
// 두 타입이 지정된 변수에 알려진 객체 리터럴을 할당하려고 하면 동일한 방법으로 타입 체크를 진행한다.

2. 인덱스 시그니처 사용 가능하다.

type TPerson = {
  [key: string]: string,
}
interface IPerson {
  [key: string]: string;
}

3. 함수 타입도 정의 가능하다.

type TFn = (x: number) => string;
interface IFn {
  (x: number): string;
}

4. 제네릭이 가능하다.

type TPair<T> = {
  first: T;
  second: T;
}
interface IPair<T> {
  first: T;
  second: T;
}

5. 인터페이스는 타입을 확장할 수 있으며, 타입은 인터페이스를 확장할 수 있다.

interface IStateWithPop extends TState {
  population: number;
}

type TStateWithPop = IState & { population: number; };
// IStateWithPop 와 TStateWithPop 는 동일하다.

단, 인터페이스는 유니온 타입 처럼 복잡한 타입을 확장하지 못한다.
복잡한 타입을 확장하고 싶다면 타입과 & 를 사용해야한다.

6. 클래스를 구현(implements)할 수 있다.

class StateT implements TState {
  name: string = '';
  capital: string = '';
}

class StateI implements IState {
  name: string = '';
  capital: string = '';
}

차이점

1. 인터페이스는 타입을 확장할 수 있지만, 유니온을 확장할 순 없다.

다음 처럼 복잡한 타입은 인터페이스로 표현할 수 없다.

type NamedVariable = (Input | Output) & { name: string };

즉, 타입은 유니온 (Input | Output)& 로 확장할 수 있지만 인터페이스로 유니온을 확장할 수는 없습니다.

2. 인터페이스로 튜플 타입을 완벽하게 구현할 수 없다.

타입은 아래 처럼 튜플을 간결하게 표현할 수 있다.

type Pair = [number, number];

하지만 인터페이스로는 아래처럼 비슷하게 구현할 수 는 있지만, Array 고차함수를 사용할 수는 없다.

interface Pair {
  0: number;
  1: number;
  length: 2;
}

3. 인터페이스는 보강(argument)이 가능하다.

인터페이스는 아래 예제처럼 선언 병합(declaration merging) 이 가능하다.

interface IPerson {
  name: string;
}
interface IPerson {
  age: number;
}
const hee: IPerson = {
  name: 'hee',
  age: 25
} // 정상

선언 병합은 주로 타입 선언 파일에서 사용한다. 선언 병합은 인터페이스에서만 가능하다.

타입스크립트는 여러 버전의 자바스크립트 표준 라이브러리에서 여러 타입을 모아 병합한다. 병합을 통해 다른 인터페이스에 추가되어 하나의 타입에 버전별로 메서드들을 모을 수 있다.

따라서 API처럼 사용자가 인터페이스를 통해 새로운 필드를 병합해야 하는 경우엔 인터페이스를 사용하고,
간단하고 일관된 타입을 사용한다면 타입을 사용하면 된다.


[아이템14] 타입 연산과 제네릭 사용으로 반복 줄이기

DRY (Don't Repeat Yourself) 원칙

같은 코드를 반복하지 말라는 원칙이다.

타입 중복은 코드 중복만큼 많은 문제를 발생시키므로 반복을 줄여야 한다.

1. 타입에 이름을 붙여라

interface Point2D{
  x: number;
  y: number;
}
function distance(a: Point2D, b: Point2D) { /* ... */ }

2. 타입을 확장시켜라

  • 인터페이스는 extends 로 확장시켜라
  • 유니온 타입에 속성 추가는 인터섹션 연산자 & 를 사용해라
    type PersonWithAge = Person & { age: number };

3. 매핑된 타입을 사용해라

interface Person {
  name: string;
  age: number;
  weight: number;
}

type TopPerson = {
  userId: Person['name'];
  age: Person['age'];
  weight: Person['weight'];
}

type TopPerson = {
  [k in 'name' | 'age' | 'weight']: Person[k] // 매핑된 타입
}

type TopPerson = Pick<Person, 'name' | 'age' | 'weight'>; // 제너릭 타입

[k in T]은 매핑된 타입은 배열의 필드를 순회하는 것 과 같은 방식이다.

4. 태그된 유니온의 태그 타입 꺼내기 (인덱싱)

다음처럼 태그된 유니온에서 type 속성의 타입을 꺼내고 싶은 경우에 반복이 발생할 수 있다

interface SaveAction {
  type: 'save';
  // ...
}
interface LoadAction {
  type: 'load';
  // ...
}
type Action = SaveAction | LoadAction; // 태그된 유니온
type ActionType = 'save' | 'load'; // 타입의 반복 !

이 경우 Action 유니온을 인덱싱 하여 타입 반복 없이 ActionType 을 정의할 수 있다.

type ActionType = Action['type']; // 타입은  'save' | 'load'

type ActionType = Pick<Action, 'type'>; // 제네릭 타입

5. 이미 선언된 타입에서 대부분이 선택적 필드가 되는 새로운 타입을 만드는 경우 (매핑된 타입과 keyof)

다음은 인스턴스가 생성되고 난 다음 프로퍼티가 업데이트 되는 클래스를 정의하는 경우다.
이 때 업데이트시 대부분의 타입들이 선택적 필드가 된다.

interface Options {
  width: number;
  height: number;
  color: string;
}
interface OptionsUpdate { // 기존의 Options타입과 동일하면서 대부분이 선택적 필드이다.
  width?: number;
  height?: number;
  color?: string;
}
class UIWidget {
  constructor (init: Options) { /* */ }
  update(options: OptionsUpdate)  { /* */ }
}

매핑된 타입과 keyof 를 사용하면 Options 로부터 OptionsUpdate 를 만들 수 있다.

type OptionsUpdate = { [k in keyof Options]?: Options[k] };

keyof 는 타입을 받아서 속성 타입의 유니온 을 반환한다.

  type OptionsKeys = keyof Options; // 'width' | 'height' || 'color'

6. 값의 형태에 해당하는 타입을 정의하고 싶을 경우 (typeof)

const INIT_OPTIONS = {
  width: 500,
  height: 550,
  color: '#00FF00',
}
type Options = typeof INIT_OPTIONS;
/**
 * 다음과 동일하다.
 * interface Options{
 *  width: number;
 *  height: number;
 *  color: string;
 * }
 * /

여기서 사용된 typeof 는 런타임 연산자가 아니라 타입스크립트 단계에서 연산되어 강력한 타입 표현이 가능하다.

하지만 값으로부터 타입을 만들어 낼 때는 선언 순서에 주의해야한다.
타입 정의를 먼저 하고 값이 그 타입에 할당 가능하다고 선언하는 것이 좋다.
그렇개 해야 타입이 더 명확해지고 예상하기 어려운 타입 변동을 방지할 수 있다.

7. 제네릭 표준 라이브러리

  • ReturnType

함수나 메서드의 반환 값에 명명된 타입을 만들고 싶은 경우 ReturnType 제네릭을 사용하면 된다.
표준 라이브러리에 이런 일반적 패턴의 제네릭 타입이 정의되어 있다.

function getUserInfo(userId: string) {
  // ...
  return {
    userId,
    name,
    age,
    height,
    weight,
  }
}
type UserInfo = ReturnType<typeof getUserInfo>; // getUserInfo 의 타입이 적용됨

후에 조건부 타입을 배워서 제네릭 표준 라이브러리를 사용하지 않고도 이를 표현할 수 있다.

  • Pick

매핑된 타입을 생성해주는 Pick 제네릭

정의가 완전하지 않지만 간단하게 표현하면 다음과 같다.

type Pick<T, K> = { [k in K]: T[k]};

정확히 표현하면 extends 를 사용해 제네릭 매개변수의 타입을 제한해주어야 한다.

type Pick<T, K extends keyof T> = { [k in K]: T[k] };
type TopState = Pick<State, 'name' | 'age' | 'weight'>;

8. 제네릭에서 매개변수를 제한하는 법 extends

interface Name {
  first: string;
  last: string;
}
type DancingDuo<T extends Name> = [T, T]; // Name 타입의 부분집합으로 매개변수 T의 타입을 제한했다.


const couple1: DancingDuo<Name> = [
    {first: 'Fred', last: 'Astaire'},
    {first: 'Giner', last: 'Rogers'},
]

const couple2: DancingDuo<{first: string}> = [
  // Error: Type '{ first: string; }' does not satisfy the constraint 'Name'.
  // Property 'last' is missing in type '{ first: string; }' but required in type 'Name'.
    {first: 'Fred'},
    {first: 'Giner'},
]
  

[아이템15] 동적 데이터에 인덱스 시그니처 사용하기

자바스크립트는 객체 리터럴로 간단하게 객체를 생성할 수 있다.

인덱스 시그니처 [key: type]: type

인덱스 시그니처는 유연하게 타입 매핑을 표현할 수 있다.

type Person = { [property: string]: string };
const hee: Person = {
  name: 'hee',
  job: 'developer',
  hobby: 'drawing'
}

인덱스 시그니처는 다음 세 가지 의미를 가지고 있다.

  • 키의 이름: 키의 위치만 표시하는 용도로 타입 체커에서는 사용되지 않는다.
  • 키의 타입: string이나 number 또는 symbol이여야 한다. 보통 string이다.
  • 값의 타입: 어떤 것이든 가능

인덱스 시그니처의 단점

  1. 잘못된 키를 포함해 모든 키를 허용한다. name 대신 Name도 가능
  2. 특정 키가 필요하지 않다. {} 도 유효하다.
  3. 키마다 다른 타입을 가질 수 없다.
    interface Person {
      [key: string]: string;
      name: string;
      age: number;
    }
    // 'age' 형식의 'number' 속성을 'string' 인덱스 유형 'string'에 할당할 수 없습니다.
  4. 언어 서비스를 제공받을 수 없다.

따라서 인덱스 시그니처는 이미 어떤 타입에 가능한 필드가 제한되어 있는 경우엔 사용하지 말아야 하며, 동적 데이터를 표현할 때 사용해야 한다.

CSV 파일의 데이터 행을 열 이름과 값으로 매핑하는 객체를 만들고 싶은 경우 처럼 동적인 경우에 사용한다.

대안

  1. 선택적 필드

    데이터 A,B,C,D 가 키로 있지만 얼마나 많이 있는지 모르는 경우 인덱스 필드 대신에 선택적 필드 로 모델링하는 것이 좋다.

    interface Row = {
      a: number;
      b?: number;
      c?: number;
      d?: number;
    }
  2. Record 제네릭 타입
    키 타입에 유연성 을 제공하는 제네릭 타입이다.

    type Vec3D = Record<'x' | 'y' | 'z', number>;
    /** 
       Type Vec3D {
          x: number;
          y: number;
          z: number;
      }
    */
  3. 매핑된 타입
    매핑된 타입은 키마다 별도의 타입 을 사용할 수 있다.

     type Vec3D = {[k in 'x' | 'y' | 'z']: number};
    
     type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string: number}; //조건부 타입
     

[아이템16] number인덱스 시그니처보다는 Array, 튜플, ArrayLike를 사용하기

자바스크립트에서 객체의 키는 string, symbol만 가능하다.
숫자나 다른 타입의 값을 키로 사용하고자 하면 자바스크립트 런타임시 문자열로 변환된다.
이는 배열도 마찬가지로 인덱스로 접근시 x[1] 인덱스가 문자열로 변환되어 x['1'] 사용된다.

타입스크립트는 이런 혼란을 잡기위해 숫자 키를 허용하고, 문자열과 다른 것으로 인식한다.

따라서 타입 체크 시점에서 배열의 인덱스에 문자열을 사용하는 오류를 잡을 수 있다.

const arr = [1, 2, 3];
const arr0 = arr[0]; // ok
const arr1 = arr['1'];  // 오류

질문. 책에서는 문자열로 접근시 오류가 발생할 것이라고 하는데, 플레이그라운드에서는 오류가 발생하지 않음.

자바스크립트에서는 배열도 객체이므로 인덱스로 접근시 숫자가 문자열로 변환된다. x[1] -> x['1'] 타입스크립트는 타입 체크에서 이를 확인하고 오류를 잡아내지만, Object.keys 로 키 배열을 만들었을 때에는 예외가 발생한다.

const xs = [1, 2, 3];

const keys = Object.keys(xs); // keys: string[]

for(const key in xs) {
  key; // key: string 
  const x = xs[key]; // key가 문자열임에도 인덱스로 접근시 타입스크립트에서 오류를 발생하지 않음
}
{ 0: 1, 1: 2, 2: 3 }; // number 인덱스 시그니처 -> 숫자 인덱스를 작성하더라도 어차피 자바스크립트에서는 인덱스로 접근시 문자열로 타입을 변경하여 접근한다. 그리고 타입스크립트는 숫자 인덱스와 문자열 인덱스를 서로 다른것으로 인식하기 때문에 문제가 발생할 수 있다.

const arr = [1, 2, 3]; // 배열 -> 숫자로 인덱스할 항목을 지정하는 경우 위처럼 객체를 사용하지 않고 배열을 사용하는것이 바람직하다.

const arrLike = { // 유사 배열 객체 -> map, forEach 같은 배열 고차함수를 사용하고 싶지 않을때 유사배열 객체를 사용해라. 하지만 키는 여전히 문자열이다.
 '0': 1,
 '1': 2,
 '2': 3,
 length: 3,
}; 

[아이템17] 변경 관련 오류 방지를 위해 readonly 사용하기

readonly 는 객체가 mutable 하다는 특징에서 발생하는 버그들을 잡을 수 있다.
즉, 객체의 프로퍼티가 변경되는 것을 방지한다.

readonly number[] 타입의 특징은 다음과 같다.

  • 배열의 요소를 읽을 수 있지만, 쓸 수는 없다
  • length를 읽을 순 있지만 바꿀 수 없다.
  • 배열을 변경하는 pop, push 같은 다른 메서드를 호출할 수 없다.

number[]readonly number[] 보다 기능이 많기 때문에, readonly number[] 의 서브타입이다.
number[]readonly number[] 에 할당할 수는 있지만, readonly number[]number[]에 할당할 수 없다.

const a: number[] = [1, 2, 3];
const b: readonly number[] = a; // 정상
const c: number[] = b; // 오류

함수 readonly 매개변수

const arr = [ 1, 2, 3 ];

const sum = (arr: readonly number []) => { // readonly 매개변수
  return arr.reduce((prev, cur) => prev + cur, 0);
}

sum(arr);
  • 타입스크립트는 매개변수가 함수 내에서 변경이 일어나는지 체크한다.
  • 함수 호출자는 함수가 매개변수를 변경하지 않는다는 보상을 받는다.
  • 함수 호출자가 함수에게 readonly 배열을 매개변수로 넘길 수 있다.
  • 하지만 반환값으로 readonly 를 반환하는 것은 함수 사용자에게 불친절한 방법일 수 있다.

readonly 를 사용하면 지역 변수와 관련된 모든 종류의 변경 오류를 방지할 수 있다.

const vs readonly

readonly 는 객체를 변경할 수 없다는 뜻이지, 변수에 재할당이 불가능하다는 의미가 아니다.

let arr: readonly number[] = [1, 2, 3];
arr.push(4); // 'readonly number[]' 형식에 'push' 속성이 없습니다.
arr = [1, 2, 3, 4]; // 정상

얕게 동작하는 readonly

readonly 는 얕게 동작한다.

만약 객체의 프로퍼티로 readonly 배열이 있더라도 객체 자체는 readonly는 아니다.

const dates: readonly Date[] = [new Date()]; // readonly 배열 
dates.push(new Date()); // readonly Date[] 형식에 push 속성이 없습니다.
dates[0].setFullYear(2022); // 정상 -> Date 객체 자체는 readonly가 아니다.
  • Readonly 제너릭도 이와 유사하게 얕게 동작한다.
interface Outer {
  inner: {
    x: number;
  }
}

const o: Readonly<Outer> = { inner: { x: 0 } };
o.inner = { x: 1 }; // 오류
o.inner.x = 1; // 정상
  • 깊은 readonly 타입을 사용하고 싶다면 ts-essentials 라이브러리의 DeepReadonly 제너릭을 사용하면 된다.

[아이템18] 매핑된 타입을 사용하여 값을 동기화하기

산덤도를 그리기위한 UI 컴포넌트를 작성하는 경우에 디스플레이와 동작을 제어하기 위한 몇 가지 타입 속성이 포함된 예를 들어보자.

interface ScatterProps {
  // data
  xs: number[];
  xs: number[];

  // display
  xRange: [number, number];
  yRange: [number, number];
  color: string;

  // events
  onClick: (x: number, y: number, index: number) => void;
}

필요할 때만 차트를 다시 그려야 하는 경우, 즉 데이터나 디스플레이 속성이 변경되면 다시 그리지만 이벤트가 변경되면 다시 그릴 필요가 없다. 리렌더링을 위해서 shouldUpdate 함수를 만들어 보자.

shouldUpdate 함수는 ScatterProps 타입에 의존적이다. 따라서 둘의 관계를 연결시켜 동기화 해주어야 한다. 그래야 ScatterProps 타입이 업데이트 되어 변경되었을 때에 타입스크립트가 타입체크를 통해 shouldUpdate 또한 업데이트가 필요하다고 알릴 수 있다.

매핑된 타입과 객체를 사용해서 동기화 할 수 있다.

const REQUIRES_UPDATE: {[k in keyof ScatterProps]: boolean} { 
  // 매핑된 타입으로 ScatterProps 가 변경되면 REQUIRES_UPDATE 도 변경됨을 알림
  xs: true,
  ys: true,
  xRange: true,
  yRange: true,
  color: true,
  onClick: false,
};

function shouldUpdate(
  oldProps: ScatterProps,
  newProps: ScatterProps
) { 
  let k: keyof ScatterProps;
  for(k in oldProps) {
    if(oldProps[k] !== newProps[k] && REQUIRES_UPDATE[k]) return true;
    return false;
  }
}
profile
🎨그림을 좋아하는 FE 개발자👩🏻‍💻

0개의 댓글