본문 바로가기

Frontend/Typescript

[이펙티브 타입스크립트] 타입을 명확하고 일관성 있게 써보자

이전에 이펙티브 타입스크립트를 한 번 읽어본 적이 있었다.
하지만, 그때는 타입스크립트는 그냥 타입만 지정하는 정도로만 알고 있었기 때문에 하나도 이해를 못 했었다.
요즘 TIFY 프로젝트를 진행하며 타입을 명확하고 안전성 있게 써보는 것에 관심이 많아졌고, 이제는 이 책을 다시 읽어볼 수 있는 단계가 된 것 같아 다시 읽어보게 되었다.
이전보다 훨씬 더 넓은 시야로 타입스크립트를 이해할 수 있는 시간이었다.
책에 있는 설명들과 내가 이해한 개념을 합쳐 글을 써보았다.
 

구조적 타이핑

자바스크립트는 덕 타이핑(구조적 타이핑) 기반으로 동작한다.
타입스크립트 또한 이 방식을 모델링해서 동작하는데, 필요한 매개 변수 값이 요구 사항을 만족한다면 신경 쓰지 않고 실행시킨다.
 
아래 예시를 한 번 살펴보자.

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

function calculateLength(v: Vector2D) {
	return Math.sqrt(v.x * v.x + v.y * v.y);
}

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

const v: NamedVector = { x: 3, y: 4, name: 'A' };
calculateLength(v);  // 5

위 코드는 정상적으로 동작한다.
 
calculateLength 함수는 Vector2D 타입을 받도록 정의하였는데, NamedVector가 들어가도 아무 문제가 없다.
이처럼 사용되는 매개 변수만 적절하게 정의되어 있다면 문제없이 동작하는 것이 구조적 타이핑이라고 생각하면 된다.
타입은 확장에 열려 있다.
 
즉, 타입에 선언된 속성 외에 임의의 속성을 추가해도 오류가 발생하지 않는다.
 
하지만 아래와 같은 경우 문제가 발생한다.

function claculateLengthL1(v: Vector3D) {
	let length = 0;

	for (const axis of Object.keys(v)) {
		const coord = v[axis];  // ~ 'string'은 'Vector3D'의 인덱스로 사용할 수 없기에 엘리먼트는 암시적으로 'any' 타입입니다.

		length += Math.abs(coord);
	}

	return length;
}

코드만 봤을 때는 전혀 문제 없이 돌아갈 것 같지만, 구조적 타이핑이라는 특성 때문에 오류로 인식한다.
 
calculateLengthL1 함수의 매개변수로 다음 값을 넣었다고 생각해 보자.

const vec3D = { x: 3, y: 4, z: 1, address: 'A' };
calculateLengthL1(vec3D);  // NaN

이렇게, 필수 매개 변수인 x, y, z 이외의 어떤 값이 들어올지 예상할 수 없고, number 타입이 아닌 값이 들어올 수 있다.
그래서 타입스크립트는 오류를 발생시키는 것이다.
 
이때는 루프를 사용하는 것보다 각각 속성들을 더 해주는 것이 낫다.

function calculateLengthL1(v: Vector3D) {
	return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z);
}

 

타입의 유니온과 인터섹션

& 연산자는 두 타입의 인터섹션을 계산한다.
 
다음 예를 한 번 살펴보자.

interface Person {
	name: string;
}

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

type PersonSpan = Person & Lifespan;

PersonSpan 타입에는 name, birth, death 세 속성, 또는 세 속성 이외의 더 많은 속성을 가지는 값이 속하게 된다.
즉, 인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함하는 것이다.
 
반면 유니온 타입에 대해서 알아보자.

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

두 인터페이스 간 공통 속성이 없기 때문에 공집합, 즉 never 타입이 되는 것이다.
 
다음 식을 보면 더 이해가 잘 될 것이다.

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

 

extends 키워드

타입은 집합이라는 관점에서 extends의 의미는 ‘~에 할당 가능한’ 또는 ‘~의 부분 집합’이라는 의미로 받아들일 수 있다.
 
아래 예시를 한 번 보자.

interface Vector1D {
	x: number;
}

interface Vector2D extends Vector1D {
	y: number
}

interface Vector3D extends Vector2D {
	z: number;
}

Vector3D는 Vector2D의 서브타입, Vector2D는 Vector1D의 서브타입이 된다.
 
extends 키워드는 제네릭 타입에서 한정자로도 쓰이고, ‘~의 부분 집합’을 의미한다.

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

getKey({}, 'x');
getKey({}, Math.random() < 0.5 ? 'a' : 'b');
getKey({}, document.title);
getKey({}, 12);

string을 상속한다는 것은 집합의 관점에서 string의 부분 집합 범위를 가지는 어떠한 타입을 의미한다.
그래서 string literal 타입, string literal 타입의 유니온, string 타입이 들어갈 수 있다.

 

튜플과 배열

아래 예시를 통해 이해해 보자.

const list = [1, 2];  // 타입은 number[]
const tuple: [number, number] = list;
// ~ 'number[]' 타입은 '[number, number]' 타입의 0, 1 속성에 없습니다.
const triple: [number, number, number] = [1, 2, 3];
const double: [number, number] = triple;
// ~ '[number, number, number]' 형식은
// `[number, number]' 형식에 할당할 수 없습니다.
// 'length' 속성의 형식이 호환되지 않습니다.
// '3' 형식은 '2' 형식에 할당할 수 없습니다.

첫 번째 예시에서는 number[]는 [number, number]의 부분 집합이 아니기 때문에 할당할 수 없다고 했다.
 
두 번째 예시에서는 double 타입을 {0: number, 1: number, length: 2}로 모델링해서 length 타입이 맞지 않아 할당할 수 없다고 했다.
 
이 관점에서 첫 번째 예시를 다시 한번 살펴보자면, list의 경우 {0: number, 1: number}을 타입으로 지정할 수 있고, tuple의 경우 {0: number, 1: number, length: 2}로 타입을 지정할 수 있기 때문에 list와 tuple을 집합 관계로 살펴볼 수 있다.
list는 tuple의 슈퍼셋이기 때문에 서브셋인 tuple에 슈퍼셋인 list를 할당하는 다운 캐스팅이 불가능해진다.
따라서 첫 번째 예시에서 에러가 발생하게 되는 것이다.
 

타입 선언 vs 타입 단언

타입 단언은 강제로 타입을 지정했으니 타입 체커에게 오류를 무시하라고 하는 것이다.
따라서, 타입 단언이 꼭 필요한 경우가 아니라면 안전성 체크도 되는 타입 선언을 사용하는 것이 좋다.
 
하지만 타입 단언이 꼭 필요한 경우도 있는데, 타입 체커가 추론한 타입보다 개발자가 판단하는 타입이 더 정확할 때 의미가 있다.
타입스크립트는 DOM에 접근할 수 없기 때문에 DOM 엘리먼트의 경우 타입 단언을 사용해줘야 한다.
또한 event의 currentTarget도 마찬가지다.
이때, null 아님 단언문을 사용해 줘도 된다.

const elNull = document.getElementById('foo');  // 타입은 HTMLElement | null
const el = document.getElementById('foo')!;  // 타입은 HTMLElement

 

잉여 속성 체크

타입이 명시된 변수에 객체 리터럴을 할당할 때 타입스크립트는 해당 타입의 속성이 있는지, 또 그 외의 속성은 없는지 확인한다.
 
다음 예를 한 번 보자.

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

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

그런데 구조적 타이핑 관점에서 보면 오류가 발생하지 않아야 할 것 같다.
 
아래 코드를 보자.

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

const r: Room = obj;  // 정상

위 코드에서 obj 타입은 Room 타입의 서브 타입이기 때문에 Room에 할당 가능하고 타입 체커도 통과하게 된다.
 
하지만, 첫 번째 코드에서는 객체 리터럴이기 때문에 잉여 속성 체크가 동작하였다.
잉여 속성 체크를 이용하면 타입 시스템의 구조적 본질을 해치지 않으면서도 객체 리터럴에 알 수 없는 속성을 허용하지 않는다.
 
또한, 타입 단언문을 사용할 때도 잉여 속성 체크는 적용되지 않는다.
따라서, 단언문보다 선언문을 사용해야 한다.
 
다음은 선택적 속성만 가지는 약한 타입에 대한 코드이다.

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

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

이런 약한 타입에 대해서 타입스크립트는 값 타입과 선언 타입에 공통된 속성이 있는지 확인하는 별도의 체크를 수행한다.
공통 속성 체크는 잉여 속성 체크와 다르게, 약한 타입과 관련된 할당문마다 수행된다.

 

타입 vs 인터페이스

대부분의 경우 타입도 가능하고, 인터페이스도 사용 가능하지만, 둘 사이 차이를 분명하게 알고, 같은 상황에서는 동일한 방법으로 명명된 타입을 정의해서 일관성을 유지해야 한다.
 
유니온 타입은 있지만, 유니온 인터페이스는 없다.
아래와 같은 타입은 인터페이스로 표현할 수 없다.

type Input = { /** **/ };
type Output = { /** **/ };

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

 
또한, type 키워드로 튜플과 배열 타입도 간결하게 표현할 수 있다.

type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];

 
튜플은 인터페이스로도 구현은 할 수 있지만, 튜플 자체 메서드를 사용할 수 없게 되니 type 키워드로 구현하는 것이 낫다.

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

const t: Tuple = [10, 20];

 
반면, 인터페이스는 타입과 다르게 보강이 가능하다.
아래와 같이 속성을 확장하는 것을 선언 병합이라고 한다.

interface State {
	name: string;
	capital: string;
}

interface State {
	population: number;
}

const wyoming: State = {
	name: 'Wyoming',
	capital: 'Cheyenne',
	population: 500_000
}

 
결론을 말하자면, 복잡한 타입이라면 바로 타입 별칭을 사용하면 된다.
하지만, 두 방법으로 모두 표현 가능한 간단한 타입이라면 일관성과 보강의 관점에서 고려해봐야 한다.