본문 바로가기

Frontend/Typescript

[이펙티브 타입스크립트] 타입스크립트의 고급 기능을 잘 활용해보자

타입의 중복 제거

태그된 유니온에서 다음과 같은 타입의 중복이 발생할 수 있다.

interface SaveAction {
	type: 'save';
	// ...
}

interface LoadAction {
	type: 'load';
	// ...
}

type Action = SaveAction | LoadAction;
type ActionType = 'save' | 'load'  // 타입 중복

 

그런 경우 아래와 같이 변경해 주면 좋다.

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

 

이렇게 타입을 지정해주면 Action 유니온에 타입을 더 추가한 경우 ActionType은 자동으로 해당 타입까지 포함하게 된다.

 

또한, 제네릭 타입을 사용하면 타입의 중복을 줄일 수 있게 된다.

제네릭 타입에서 매개 변수를 제한할 수 있는 방법은 extends 키워드를 사용하는 방법이다.

extends 키워드는 확장의 관점보다 부분 집합의 관점으로 이해하면 좋다.

interface Name {
	first: string;
	last: string;
}

type DancingDuo<T extends Name> = [T, T];

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

 

readonly

함수가 매개 변수를 수정하지 않는다면 readonly로 선언해야 한다.

이렇게 하면 변경 오류를 방지할 수 있고 타입 안전성을 높일 수 있다.

 

이제 const, as const, readonly의 차이를 한 번 알아보자.

 

먼저 const부터 살펴보자.

사실 const로 선언하면 어떤 값이라도 변경될 수 없는 걸로 알고 있는 사람들이 많은데, 아니다.

상수 값이라면 변경될 수 없지만, 객체나 배열이라면 내부 속성이나 요소는 변경될 수 있다.

즉, 주소 값은 변경되지 않지만 내부 요소들은 변경이 가능하다는 것이다.

const obj = {
	name: 'banana',
	color: 'yellow'
};

const arr = [1, 2];

obj.color = 'red';
arr.push(6);

 

as const는 const에서 객체나 배열이 변경 가능했던 부분을 불가능하게 한다.

const obj = {
	name: 'banana',
	color: 'yellow'
} as const;

const arr = [1, 2] as const;

obj.color = 'red';  // 불가능
arr.push(6);  // 불가능

 

readonly는 변수 외의 클래스, 인터페이스, 타입 등의 속성을 읽기 전용으로 만들기 위한 것이다.

interface Person {
	readonly name: string;
	color: string;
}

const person: Person = {
	name: 'John',
	age: 30
};

person.name: 'Jon'  // 불가능

 

주로 as const는 속성들 전체 또는 변수, 즉 값 자체를 불변하게 만들 때, readonly객체나 배열의 일부를 불변하게 만들 때 사용한다고 보면 된다.

또, const는 단지 값이 가리키는 참조가 변하지 않는 얕은 상수이지만 as const는 그 값이 내부까지 상수라는 사실을 타입스크립트에게 알려준다.

 

타입 추론

함수의 반환에 타입을 명시하면 오류를 방지할 수 있다.

타입 추론이 가능하더라도 구현 상의 오류가 함수를 호출한 곳까지 영향을 미치지 않도록 하기 위해서 타입 구문을 명시하는 것이 좋다.

 

아래 예시를 한 번 보자.

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

function add(a: Vector2D, b: Vector2D) {
	return { x: a.x + b.x, y: a.y + b.y };
}

위 예시에서 타입스크립트는 반환 타입을 { x: number; y: number; }로 추론했다.

하지만 기대하는 타입은 Vector2D였다.

 

따라서, 명확하게 반환 타입을 지정해서 의도한 대로 타입을 사용하자.

 

또한 객체 리터럴에 타입을 명시해야 객체를 선언할 때 잉여 속성 체크가 동작하게 되는데, 그렇지 않다면 객체가 사용되는 곳에서 타입 오류가 발생할 수 있다.

interface Product {
	name: string;
	id: string;
	price: number;
}

const furby = {
	name: 'Furby',
	id: 62,
	price: 35
}

logProduct(furby);
// ~ ... 형식의 인수는 'Product' 형식의 매개변수에 할당될 수 없습니다.
// 'id' 속성의 형식이 호환되지 않습니다.
// 'number' 형식은 'string' 형식에 할당할 수 없습니다.
const furby: Product = {
	name: 'Furby',
	id: 62,
// ~ 'number' 형식은 'string' 형식에 할당할 수 없습니다.
	price: 35
}

 

타입 넓히기

런타임에 모든 변수는 유일한 값을 가진다.

하지만 타입스크립트가 작성된 코드를 체크하는 정적 분석 시점에 변수는 가능한 값들의 집합인 타입을 가지는데, 단일 값을 가지고 할당 가능한 값들의 집합을 유추하는 과정넓히기라고 부른다.

 

타입스크립트의 넓히기 과정을 제어할 수 있는 방법이 몇 가지 있는데, 첫 번째 방법은 let 대신 const로 선언하기이다.

const x = 'x';  // 타입이 'x'
let x = 'x';  // 타입이 string

 

let 대신 const로 변수를 선언하면 더 좁은 타입이 된다.

하지만, 객체와 배열의 경우에는 const도 좁은 타입을 지정해 줄 수 없는 문제가 있다.

const mixed = ['x', 1];  // 타입은 (string|number)[]

const v = {
	x: 1
}
// 타입은 { x: number }

 

객체의 경우 타입스크립트의 넓히기 알고리즘은 각 요소를 let으로 할당된 것처럼 다루기 때문에 { x: number } 타입이 된다.

 

타입 추론의 강도를 제어하려면 타입스크립트의 기본 동작을 재정의해야 한다.

명시적 타입 구문을 제공하는 것도 방법이 될 수 있다.

const v: { x: 1|3|5 } = {
	x: 1;
}
// 타입이 { x: 1|3|5 }

 

const 단언문을 사용해도 된다.

const v1 = {
	x: 1,
	y: 2
};  
// 타입은 { x: number; y: number; }

const v2 = {
	x: 1 as const,
	y: 2
}; 
// 타입은 { x: 1; y: number; }

const v3 = {
	x: 1,
	y: 2,
} as const;
// 타입은 { readonly x: 1; readonly y: 2; }

 

값 뒤에 as const를 작성하면 타입스크립트는 최대한 좁은 타입으로 추론한다.

 

배열을 튜플 타입으로 추론할 때도 as const를 사용할 수 있다.

const a1 = [1, 2, 3];  // 타입이 number[]
const a2 = [1, 2, 3] as const;  // 타입이 readonly [1, 2, 3]

 

타입 좁히기

타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 말한다.

타입 좁히기에는 null 체크, 속성 체크, instanceof 키워드를 사용한 타입 좁히기, 명시적 태그를 통한 타입 좁히기 등 많은 방식이 있다.

interface A {
	a: number;
}

interface B {
	b: number;
}

function pickAB(ab: A | B) {
	if ('a' in b) {
		ab;  // 타입이 A
	} else {
		ab;  // 타입이 B
	}
	ab;  // 타입이 A|B
}

위 코드는 속성 체크를 통한 타입 좁히기이다.

타입스크립트가 타입을 식별하지 못한다면 식별을 돕기 위해서 커스텀 함수를 도입할 수도 있다.

 

function isInputElement(el: HTMLElement): el is HTMLInputElement {
	return 'value' in el;
}

위 기법은 사용자 정의 타입 가드이다.

 

또한, 배열에서 탐색을 수행할 때 undefined가 될 수 있는 타입을 사용하는 경우, 타입 가드를 사용하면 타입을 좁힐 수 있다.

const members = ['janet', 'michael'].map(who => jackson5.find(n => n === who));  // 타입이 (string|undefined)[]

const members = ['janet', 'michael'].map(who => jackson5.find(n => n === who).filter(who => who !== undefined);  // 타입이 (string|undefined)[]

function isDefined<T>(x: T | undefined): x is T {
	return x !== undefined;
}

const members = ['janet', 'michael'].map(who => jackson5.find(n => n === who).filter(isDefined);  // 타입이 string[]

 

조건부 속성

타입에 안전한 방식으로 조건부 속성을 추가하는 방법이 있다.

아래와 같이 객체 전개 연산자를 사용하는 것이다.

let hasMiddle: boolean;
const firstLast = { first: 'Harry', last: 'Truman' };
const president = {...firstLast, ...(hasMiddle ? { middle: 'S' } : {})};
let hasDates: boolean;
const nameTitle = { name: 'Khufu', title: 'Pharaoh' };
const pharaoh = {
	...nameTitle,
	...(hasDates ? { start: -2589, end: -2566 } : {})
}

const pharaoh: {
	start: number;
	end: number;
	name: string;
	title: string;
} | {
	name: string;
	title: string;
}

위 경우 항상 start와 end가 함께 정의되므로, 둘 다 선택적 필드로 정의했을 때보다 훨씬 타입 안전성 있게 사용할 수 있게 된다.

 

유니온의 인터페이스 vs 인터페이스의 유니온

타입 설계 시 유니온의 인터페이스와 인터페이스의 유니온 중 어떤 것을 사용해야 할지 고민이 될 수 있다.

결론부터 말하자면 인터페이스의 유니온을 사용하는 것이 좋다.

 

아래 예시를 보자.

// 유니온의 인터페이스
interface Layer {
	layout: FillLayout | LineLayout | PointLayout;
	paint: FillPaint | LinePaint | PointPaint;
}
// 인터페이스의 유니온
interface FillLayer {
	layout: FillLayout;
	paint: FillPaint;
}

interface LineLayer {
	layout: LineLayout;
	paint: LinePaint;
}

interface PointLayer {
	layout: PointLayout;
	paint: PointPaint;
}

type Layer = FillLayer | LineLayer | PointLayer;

유니온의 인터페이스라면 예상치 못한 조합이 나올 수 있다.

즉, 속성 간의 관계가 분명하지 않아 실수가 발생할 가능성이 높다.

layout은 FillLayout, paint는 LinePaint인 경우와 같은 것 말이다.

 

인터페이스의 유니온을 사용해서 유효한 상태만을 표현하도록 하자.

이때, 태그된 유니온 형식으로 표현해 주면 타입스크립트가 제어 흐름을 분석하기에 더 용이하다.

 

상표

타입스크립트는 구조적 타이핑을 사용하기 때문에 값을 세밀하게 구분하지 못하는 경우가 있다.

이를 위해서 공식 명칭이 필요하다면 상표를 붙일 수 있다.

태그된 유니온과 비슷하게, 상표를 붙여 타입을 지정해 줄 수 있다.

 

아래 예시를 보자.

interface Vector2D {
	_brand: '2d',
	x: number;
	y: number;
}

function vec2D(x: number, y: number): Vector2D {
	return { x, y, _brand: '2d' };
}

상표 기법은 타입 시스템에서 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있다.

 

상표 기법은 주로 타입 정보를 부여하고 타입 검사를 보조하는데 사용되고, 태그된 유니온은 여러 다른 타입의 값을 그룹화하고 식별하기 위해서 사용된다.

 

any의 진화

다음 예시를 통해 any의 진화에 대해서 알아보자.

function range(start: number, limit: number) {
	const out = [];  // 타입이 any[]
	for (let i = start; i < limit; i++) {
		out.push(i);  // out의 타입이 any[]
	} 

	return out;  // 타입이 number[]
}

out의 타입은 any[]이지만, number 타입의 값을 넣는 순간부터 타입은 number[]로 진화한다.

이런 any 타입의 진화는 변수의 타입이 암시적 any인 경우에만 일어난다.

 

명시적 any 타입의 경우 계속해서 any 타입이 유지된다.

일반적인 타입들은 정제되기만 하지만, 암시적 any와 any[] 타입은 진화할 수 있다.

 

타입을 안전하게 지키기 위해서는 암시적 any를 진화시키는 방식보다 명시적 타입 구문을 사용하는 것이 더 좋은 설계이다.

 

any vs unknown

모르는 값에는 any보다 unknown을 사용하는 것이 좋다.

 

any는 다음 두 특징을 가지고 있다.

- 어떠한 타입이든 any 타입에 할당 가능하다.

- any 타입은 어떠한 타입으로도 할당 가능하다.

이 두 특징 때문에 타입스크립트의 타입 시스템과 상충되는 면이 있어, 타입 체커를 무용지물이 되도록 한다.

 

unknown은 any 대신 쓸 수 있는 타입 시스템에 부합하는 타입이다.

어떠한 타입이든 unknown 타입에 할당 가능하다.라는 특징만 가지고 있다.

 

unknown 타입의 값은 타입 단언을 통해 적절한 타입으로 변환해 사용할 수 있다.

또한, instanceof 키워드를 통해 원하는 타입으로 변환할 수도 있다.

function processValue(val: unknown) {
	if (val instanceof Date) {
		val;  // 타입이 Date
	}
}

 

사용자 정의 타입 가드도 unknown에서 원하는 타입으로 변환할 수 있다.

function isBook(val: unknown): val is Book {
	return (
		typeof (val) === 'object' && val !== null && 'name' in val && 'author' in val
	);
}

 

조건부 타입

여러 타입의 매개 변수가 들어가는 함수를 작성할 때는 오버 로딩을 사용할 수 있다.

// 오버 로딩 방식
function double(x: number); number;
function double(x: string): string;
function double(x: any) { return x + x; }

function f(x: number|string) {
	return double(x);
}
// ~'string | number' 형식의 인수는 'string' 형식의 매개 변수에 할당될 수 없습니다.

 

하지만, 오버 로딩 방식은 타입이 독립적으로 처리되는 반면, 조건부 타입은 타입 체커가 단일 표현식으로 받아들여 유니온 문제를 해결할 수 있다.

// 조건부 타입
function double<T extends number | string>(
	x: T
): T extends string ? string : number;
function double(x: any) { return x + x; }

double(12);
double('x');

따라서, 여러 타입의 매개 변수를 요구하는 함수를 작성할 때는 오버 로딩 방식보다 조건부 타입을 사용하자.

 

객체 순회

객체 순회 시 다음과 같이 오류가 발생하는 경우가 생길 수 있다.

const obj = {
	one: 'uno',
	two: 'dos',
	three: 'tres'
};

for (const k in obj) {
	const v = obj[k];
}
// ~ obj에 인덱스 시그니처가 없기 떄문에 엘리먼트는 암시적으로 'any' 타입입니다.

k의 타입은 string이지만 obj 객체에는 ‘one’, ‘two’, ‘three’ 세 키만 존재해서 k와 obj 객체의 키 타입이 서로 다르게 추론되어 오류가 발생한 것이다.

 

아래와 같이 k의 타입을 더 구체적으로 명시해주면 오류는 사라진다.

let k: keyof typeof obj; // 'one' | 'two' | 'three' 타입
for (k in obj) {
	const v = obj[k];
}

 

후기

최근 올린 TIFY 프로젝트 개발기 중 타입스크립트 관련 글들을 보면, 이펙티브 타입스크립트에서 제시한 방식들과 동일한 맥락이다.

사실, TIFY 프로젝트 진행하면서 타입과 관련된 오류를 많이 마주치고, 해당 오류를 고치면서 많이 배워나갔기 때문에 다시 이펙티브 타입스크립트를 읽으면서 이게 이런 내용이구나 하는 게 많았던 거 같다.

처음에 읽어봤을 때는 약간, 이런 게 있구나 근데 나는 못 쓸 거 같은데? 라는 느낌이었다면, 지금은 아 이런 게 있었지, 지금 코드도 이 방식으로 리팩터링해서 타입 중복도 없고 좀 더 안전성 있게 쓸 수 있겠구나 하는 느낌이다.

이 책의 진가를 이제 안 거 같다.