12. 함수 표현식에 타입 적용하기
js/ts에서는 함수 선언식과 표현식을 다르게 인식한다. 타입스크립트에서는 함수 표현식을 사용하는 것이 좋다.
매개변수나 반환 값에 타입을 명시하기보다는 함수 표현식 전체에 타입 구문을 적용하는 것이 좋다. 더 간결하고 안전하다.
만약 같은 타입 시그니처를 반복적으로 작성한 코드가 있다면 함수 타입을 분리해 내자.
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;
라이브러리는 공통 함수 시그니처를 타입으로 제공하기도 한다.
리액트는 함수의 매개변수에 명시하는 MouseEvent 타입 대신에 함수 전체에 적용할 수 있는 MouseEventHandler 타입을 제공한다.
라이브러리를 직접 만든다면 공통 콜백 함수를 위한 타입 선언을 제공하는 것이 좋다.
시그니처가 일치하는 다른 함수가 있을 때도 함수 표현식에 타입을 적용해볼 만하다.
async function getQuote() {
const response = await fetch("/quote?by=Mark_Twain"); // 타입이 Promise<Response>
const quote = await response.json();
return quote;
}
/quote가 존재하지 않는 API라면, 404를 응답해야 하지만, 응답은 JSON 형식이 아닐 수 있고,
response.json()은 JSON 형식이 아니라는 오류로 rejected 프로미스를 반환하기 때문에 실제 오류인 404가 감추어진다.
declare function fetch(
input: RequestInfo,
init?: RequestInit
): Promise<Response>;
// 다른 함수의 시그니처를 참조하려면 typeof fn을 사용하면 된다.
const checkedFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error("Request failed: " + response.status);
}
return response;
};
13. 타입과 인터페이스의 차이점 알기
대부분의 경우 어느 쪽을 사용해도 무관하지만, 둘의 차이를 알고 같은 상황에서는 동일한 방법으로 정의해 일관성을 유지해야 한다.
인터페이스로 정의하든 타입으로 정의하든 상태에는 차이가 없다.
// 인덱스 시그니처
type TDict = { [key: string]: string };
interface IDict {
[key: string]: string;
}
// 함수 타입
type TFn = (x: number) => string;
interface IFn {
(x: number): string;
}
// 클래스 implements
class StateT implements TDict {
name: string = "";
}
class StateI implements IDict {
name: string = "";
}
인터페이스는 타입을 확장할 수 있고, 타입은 인터페이스를 확장할 수 있다.
type TState = {
name: string;
capital: string;
};
interface IState {
name: string;
capital: string;
}
type TStateWithPop = IState & { population: number };
interface IStateWithPop extends TState {
population: number;
}
인터페이스는 유니온 타입 같은 복잡한 타입을 확장하지는 못한다. 복잡한 타입은 타입과 &를 사용해야 한다.
type NamedVariable = (Input | Output) & { name: string };
이는 인터페이스로 표현할 수 없다. type 키워드는 일반적으로 interface보다 스임새가 많다.
type은 유니온이 될 수도 있고, 매핑된 타입 또는 조건부 타입 같은 고급 기능에 활용되기도 한다.
튜플과 배열 타입도 type으로 더 간결하게 표현할 수 있다.
type TTuple = [number, number];
interface ITuple {
0: number;
1: number;
length: 2;
}
type StringList = string[];
type NamedNums = [string, ...number[]];
인터페이스로 튜블을 구현하면 concat과 같은 메서드들을 사용할 수 없다.
인터페이스는 타입과 달리 보강(augment)이 가능하다. (선언 병합)
interface IState {
name: string;
capital: string;
}
interface IState {
population: number;
}
const wyoming: IState = {
name: "Wyoming",
capital: "Cheyenne",
population: 500_000,
};
타입 선언 파일을 작성할 때는 사용자가 채워야 하는 빈틈이 있을 수 있기 때문에 선언 병합을 지원해야 한다.
타입스크립트는 여러 버전의 자바스크립트 표준 라이브러리에서 여러 타입을 모아 병합한다.
각 선언이 병합되어 전체 메서드를 가지는 하나의 타입을 얻게 된다.
타입은 기존 타입에 추가적인 보강이 없는 경우에만 사용해야 한다.
복잡한 타입이라면 고민할 것도 없이 타입을 사용하자.
그러나 간단한 객체 타입이라면 일관성과 보강의 관점에서 고려해야 한다.
아직 스타일이 확립되지 않은 프로젝트라면 향후에 보강의 가능성이 있을지 생각해야 한다.
API에 대한 타입 선언의 경우 인터페이스를 사용하는 게 좋다.
API가 변경될 때 사용자가 인터페이스를 통해 새로운 필드를 병합할 수 있어 유용하다.
14. 타입 연산과 제너릭 사용으로 반복 줄이기
DRY(don't repeat yourself) 원칙을 타입에도 최대한 적용해야 한다.
타입 중복은 코드 중복만큼 많은 문제를 발생시킨다. 타입 간에 매핑하는 방법을 익혀야 한다.
타입에 이름을 붙여 반복을 피애햐 한다. extends를 사용해서 인터페이스 필드의 반복을 피해야 한다.
// 개선 전
function distance(a: { x: number; y: number }, b: { x: number; y: number }) {}
// 개선 후
interface Point2D {
x: number;
y: number;
}
function distance(a: Point2D, b: Point2D) {}
같은 시그니처를 공유하는 경우 타입으로 분리해내자.
// 개선 전
function get(url: string, opts: Options): Promise<Response> {}
function post(url: string, opts: Options): Promise<Response> {}
// 개선 후
type HTTPFunction = (url: string, opts: Options) => Promise<Response>;
const get: HTTPFunction = (url, opts) => {};
const post: HTTPFunction = (url, opts) => {};
타입들 간의 매핑을 사용하자. (keyof, typeof, 인덱싱, 매핑된 타입)
// 개선 전
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
interface TopNavState {
userId: string;
pageTitle: string;
recentFiles: string[];
}
// State를 인덱싱하여 중복 제거
type TopNavState = {
userId: State["userId"];
pageTitle: State["pageTitle"];
recentFiles: State["recentFiles"];
};
// 매핑된 타입 사용
type TopNavState = {
[k in "userId" | "pageTitle" | "recentFiles"]: State[k];
};
값의 형태에 해당하는 타입을 정의하고 싶으면 typeof를 사용하면 된다. (타입 정의를 먼저 하는 것이 좋다.)
type Options = typeof INIT_OPTIONS;
const INIT_OPTIONS = {
width: 640,
color: "#000",
};
매핑된 타입은 배열의 필드를 루프 도는 것과 같은 방식이다.
표준 라이브러리에 정의된 Pick, Partial, ReturnType 같은 제너릭 타입(함수를 위한 함수)을 사용할 수도 있다.
// type Pick<T, K> = { [k in K]: T[k] };
type TopNavState = Pick<State, "userId" | "pageTitle" | "recentFiles">;
interface OptionsState {
userId?: string;
pageTitle?: string;
recentFiles?: string[];
pageContents?: string;
}
type OptionsState = Partial<Options>
ReturnType은 함수나 메서드의 반환 값에 명명된 타입을 만들 때 쓴다. ReturnType은 함수의 값이 아닌 타입에 적용한다.
function getUserInfo() {
return {
useId,
name,
age,
};
}
type UserInfo = ReturnType<typeof getUserInfo>;
태그된 유니온에서도 중복이 발생할 수 있다. 유니온을 인덱싱하면 타입 반복 없이 타입을 정의할 수 있다.
이는 Pick을 사용하여 얻게 되는, 해당 속성을 가지는 인터페이스와는 다르다
// 개선 전
interface SaveAction {
type: "save";
}
interface LoadAction {
type: "load";
}
type Action = SaveAction | LoadAction;
type ActionType = "save" | "load"; // 중복!
// 개선 후
type ActionType = Action["type"]; // 타입은 "save" | "load"
// type ActionType = Pick<Action, "type">; // 타입은 {type: "save" | "load"}
제너릭 타입에서 extends를 사용하여 매개변수를 제한할 수 있다.
interface Name {
first: string;
last: string;
}
type DancingDuo<T extends Name> = [T, T];
const couple: DancingDuo<Name> = [
{ first: "", last: "" },
{ first: "", last: "" },
];
앞의 Pick의 정의를 extends를 사용해서 완성할 수 있다.
type Pick<T, K> = {
[k in K]: T[k];
// 'K' 타입은 'string | number | symbol' 타입에 할당할 수 없습니다.
};
K는 T 타입과 무관하고 범위가 너무 넓다. K는 실제로 T의 키의 부분 집합이 되어야 한다.
type Pick<T, K extends keyof T> = {
[k in K]: T[k];
};
15. 동적 데이터에 인덱스 시그니처 사용하기
타입스크립트에서는 타입에 인덱스 시그니처를 명시하여 유연하게 매핑을 표현할 수 있다.
type Name = { [property: string]: string };
const name1: Name = {
first: "",
last: "",
};
[property: string]: string이 인덱스 시그니처이다.
- 키의 이름: 키의 위치만 표시. 타입 체커에서는 사용하지 않음
- 키의 타입: string | number | symbol (보통은 string)
- 값의 타입: 무엇이든 가능
여기에는 단점이 있다.
- 잘못된 키를 허용함. first 대신 First로 작성해도 유효한 타입
- 특정 키가 필요하지 않음. {}도 유효한 타입
- 키마다 다른 타입을 가질 수 없음. age는 string이 아닌 number일수도 있음
- 키는 무엇이든 가능하기 때문에 자동 완성 기능이 동작하지 않음
인덱스 시그니처는 부정확하다. 동적 데이터를 표현할 때 사용해야 한다.
런타임 때까지 객체의 속성을 알 수 없을 경우에만(ex. CSV파일에서 로드하는 경우) 인덱스 시그니처를 사용하자.
그럼에도 런타임 에러가 걱정된다면 안전한 접근을 위해 인덱스 시그니처의 값 타입에 undefined를 추가할 수도 있다.
string 타입이 너무 광범위해서 인덱스 시그니처를 사용하는 데 문제가 있다면 두 가지 대안이 있다.
1. Record 사용하기. Record는 키 타입에 유연성을 제공하는 제너릭 타입이다. string의 부분 집합을 사용할 수 있다.
type Vec3D = Record<"x" | "y" | "z", number>;
// type Vec3D = {
// x: number;
// y: number;
// z: number;
// };
2. 매핑된 타입 사용하기. 키마다 별도의 타입을 사용할 수도 있다.
type Vec3D = { [k in "x" | "y" | "x"]: number };
// type Vec3D = {
// x: number;
// y: number;
// z: number;
// };
type ABC = { [k in "a" | "b" | "c"]: k extends "b" ? string : number };
// type ABC = {
// a: number;
// b: string;
// c: number;
// };
가능하다면 인터페이스, Record, 매핑된 타입 같은 인덱스 시그니처보다 정확한 타입을 사용하는 것이 좋다.
16. number 인덱스 시그니처보다는 Array, 튜플, ArrayLike를 사용하기
자바스크립트에서 객체란 키/값 쌍의 모음이다. 키는 문자열/심벌이 될 수 있고, 값은 어떤 것이든 될 수 있다.
파이썬이나 자바의 '해시 가능' 객체가 자바스크립트에는 없다.
복잡한 객체를 키로 사용하려고 하면, toString 메서드가 호출되어 객체가 문자열로 변환된다.
x = {};
x[[1, 2, 3]] = 2;
// x = { '1,2,3': 1}
특히, 숫자는 키로 사용할 수 없다. 배열 또한 객체이므로 키는 숫자가 아니라 문자열이다.
x = [1, 2, 3];
x[0]; // 1
x["1"]; // 2
Object.keys(x); // ['0', '1', '2']
문자열 키로도 배열의 요소에 접근할 수 있고, 배열의 키를 나열해 보면 키가 문자열로 출력된다.
타입스크립트는 이러한 혼란을 위해 숫자키를 허용하고, 문자열 키와 다른 것으로 인식한다.
인덱스 시그니처로 사용된 number 타입은 버그를 잡기 위한 순수 타입스크립트 코드이다.
실제 런타임에 사용되는 키는 string 타입이다.
타입이 불확실하다면 for-in은 대부분의 상황에서 훨씬 느리다.
인덱스에 신경 쓰지 않는다면 for-of를, 인덱스의 타입이 중요하다면 forEach를, 루프 중간에 멈춰야 한다면 for(;;) 루프를 사용하자.
Array 타입이 push나 concat을 가지는 것이 납득하기 어려울 수도 있다.
어떤 길이를 가지는 배열과 비슷한 형태의 튜플을 사용하고 싶다면 ArrayLike 타입을 사용하자.
길이와 숫자 인덱스 시그니처만 가진다.
function checkedAccess<T>(xs: ArrayLike<T>, i: number): T {
if (i < xs.length) {
return xs[i];
}
throw new Error();
}
// 그래도 키는 여전히 문자열
const tupleLike: ArrayLike<string> = {
0: "a",
"1": "b",
length: 2,
}; // 정상
17. 변경 관련된 오류 방지를 위해 readonly 사용하기
삼각수를 출력하는 코드를 보자.
function arraySum(arr: number[]) {
let sum = 0,
num;
while ((num = arr.pop()) !== undefined) {
sum += num;
}
return sum;
}
function printTriangles(n: number) {
const nums = [];
for (let i = 0; i < n; i++) {
nums.push(i);
console.log(arraySum(nums));
}
}
printTriangles(4);
0, 1, 3, 7 이 출력되어야 하는데 0, 1, 2, 3 이 출력된다. 배열이 변경되기 때문이다.
만약 함수가 매개변수를 수정하지 않는다면 readonly로 선언하는 것이 좋다.
인터페이스를 명확하게 하며, 매개변수가 변경되는 것을 방지한다.
function arraySum(arr: readonly number[]) {
let sum = 0;
for (const num of arr) {
sum += num;
}
return sum;
}
readonly number[]는 타입이고, number[]와의 차이점이 있다.
- 배열의 요소를 읽을 순 있지만 쓸 순 없다.
- lenght를 읽을 순 있지만 바꿀 순 없다.
- 배열을 변경하는 pop을 비롯한 메서드를 호출할 수 없다.
number[]은 readonly number[]보다 기능이 많기 때문에, readonly number[]의 서브타입이다.
매개변수를 readonly로 선언하면 다음과 같은 일이 생긴다.
- 타입스크립트는 매개변수가 함수 내에서 변경이 일어나는지 체크한다.
- 호출하는 쪽에서는 함수가 매개변수를 변경하지 않는다는 보장을 받게 된다.
- 호출하는 쪽에서 함수에 readonly 배열을 매개변수로 넣을 수 있다.
JS/TS에서는 명시적으로 언급하지 않는 한, 함수가 매개변수를 변경하지 않는다고 가정한다.
하지만 이러한 암묵적인 방법은 타입 체크에 문제를 일으킬 수 있다. 명시적인 방법을 사용하자.
readonly는 얕게 동작한다. 객체의 readonly 배열이 있다면, 그 객체 자체는 readonly가 아니다.
const dates: readonly Date[] = [new Date()];
dates.push(new Date()); // 불가능
dates[0].setFullYear(2037); // 가능
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 제너릭을 사용할 수 있다.
인덱스 시그니처에서도 readonly를 쓸 수 있다. 읽기는 허용하되 쓰기를 방지할 수 있다.
객체의 속성이 변경되는 것을 방지할 수 있다.
let obj: { readonly [k: string]: number } = {};
obj.hi = 45; // 불가능
obj = { ...obj, hi: 45 }; // 가능
18. 매핑된 타입을 사용하여 값을 동기화하기
UI를 리렌더링하는 예시를 보자.
interface Props {
xs: number[];
ys: number[];
}
// 최적화1
function shouldUpdate1(oldProps: Props, newProps: Props) {
let k: keyof Props;
for (k in oldProps) {
if (oldProps[k] !== newProps[k]) {
return true;
}
}
return false;
}
// 최적화2
function shouldUpdate2(oldProps: Props, newProps: Props) {
return oldProps.xs !== newProps.xs || oldProps.ys !== newProps.ys;
}
최적화1은 보수적 접근법(실패에 닫힌 접근법)이다. 새로운 속성이 추가되면 값이 변경될 때마다 차트를 그릴 것이다.
정확하지만 너무 자주 그려질 가능성이 있다.
최적화2는 실패예 열린 접근법이다. 불필요하게 다시 그리는 단점은 없지만, 실제로 다시 그려야 할 경우에 누락될 수 있다.
결국, 새로운 속성이 추가될 때 타입 체커가 shouldUpdate를 확인하도록 하면 된다.
매핑된 타입을 사용해 타입스크립트가 코드에 제약을 강제하도록 할 수 있다.
const REQUIRES_UPDATE: { [k in keyof Props]: boolean } = {
xs: true,
ys: true,
};
function shouldUpdate(oldProps: Props, newProps: Props) {
let k: keyof Props;
for (k in oldProps) {
if (oldProps[k] !== newProps[k] && REQUIRES_UPDATE[k]) {
return true;
}
}
return false;
}
새로운 속성이 추가되면 REQUIRES_UPDATE의 정의에 오류가 발생할 것이다.
매핑된 타입은 한 객체가 또 다른 객체와 정확히 같은 속성을 가지게 할 때 이상적이다.