타입스크립트는 타입 추론을 적극적으로 수행한다.
숙련된 타입스크립트 개발자는 비교적 적은 수의 구문을 사용한다.
19. 추론 가능한 타입을 사용해 장황한 코드 방지하기
타입스크립트가 타입을 추론할 수 있다면 타입 구문을 작성하지 않는 게 좋다. 오히려 방해가 될 뿐이다.
// 비생산적이며 형편없는 스타일
let x: number = 12;
let x = 12; // 로 충분하다.
타입스크립트는 예상보다 더 정확하게 추론하기도 한다.
const axis1: string = 'x'; // 타입은 string
const axis2 = 'y'; // 타입은 "y"
불필요한 명시적 타입 구문은 리팩터링을 더 번거롭게 한다.
비구조화 할당문은 모든 지역 변수의 타입이 추론되도록 하기 때문에 명시적 타입 구문을 넣는다면 더 번잡해질 뿐이다.
interface Product {
id: number;
name: string;
}
function logProduct(product: Product) {
const { id, name } = product;
console.log(id, name);
}
이상적인 경우 함수/메서드의 시그니처에는 타입 구문이 있지만, 함수 내의 지역 변수에는 타입 구문이 없다.
함수 매개변수에 기본값이 있다면 타입 구문을 생략할 수도 있다.
보통 타입 정보갸 있는 라이브러리에서, 콜백 함수의 매개변수 타입은 자동으로 추론된다.
추론될 수 있는 경우라도 객체 리터럴과 함수 반환에는 타입 명시를 고려해야 한다.
1. 객체 리터럴 정의에 타입을 명시하면 잉여 속성 체크를 할 수 있다.
만약 타입 구문을 제거한다면 잉여 속성 체크가 동작하지 않고, 객체 선언 부분이 아닌 객체 사용 부분에서 타입 오류가 발생한다.
const product1: Product = {
id: "1", // 여기에서 오류 발생
name: "Furby",
};
logProduct(product1);
const product2 = {
id: "1",
name: "Furby",
};
logProduct(product2); // 여기에서 오류 발생
2. 함수의 반환에도 타입을 명시하면 구현상의 오류가 함수를 호출한 곳까지 영향을 미치지 않게 할 수 있다.
// 조회한 종목을 다시 요청하지 않도록 하는 캐시 함수
const cache: { [ticker: string]: number } = {};
// 주식 시세 조회 함수
function getQuote(ticker: string) {
if (ticker in cache) {
return cache[ticker];
}
return fetch("")
.then((res) => res.json())
.then((quote) => {
cache[ticker] = quote;
return quote;
});
}
getQuote("").then();
// 'number | Promise<any>' 형식에 'then' 속성이 없습니다.
getQuote는 항상 Promise를 반환하므로 if 구문에는 cache[ticker]가 아니라 Promise.resolve(cache[ticker])를 반환해야 한다.
그러나 오류는 getQuote 내부가 아닌 호출 코드에서 발생한다.
따라서 의도된 반호나 타입을 명시해줘야 한다.
function getQuote(ticker: string): Promise<number> {
if (ticker in cache) {
return cache[ticker]; // 여기에서 오류 발생
}
return fetch("")
.then((res) => res.json())
.then((quote) => {
cache[ticker] = quote;
return quote;
});
}
뿐만 아니라
1. 반환 타입을 명시하면 함수에 대해 더욱 명확하게 알 수 있다.
미리 타입을 명시하는 방법은 TDD와 비슷하다. 전체 타입 시그니처를 먼저 작성하도록 하자.
2. 반환 타입을 명시하면 명명된 타입을 사용할 수 있다.
interface Vector2D {
x: number;
y: number;
}
function add(a: Vector2D, b: Vector2D) {
return { x: a.x + b.x, y: a.y + b.y };
}
타입을 확인해보면, 입력은 Vector2D인데 출력은 { x: number; y: number; }이다.
반환 타입을 명시하면 더욱 직관적인 표현이 된다.
eslint 규칙 중 no-inferrable-types을 사용해서 작성된 모든 타입 구문이 정말로 필요한지 확인할 수 있다.
20. 다른 타입에는 다른 변수 사용하기
자바스크립트에서는 한 변수를 다른 타입으로 재사용해도 된다. 반면 타입스크립트에서는 오류가 발생한다.
let id = "123";
id = 123;
"123"에서 id의 타입을 string으로 추론했기 때문이다.
즉, 변수의 값은 바뀔 수 있지만 타입은 일반적으로 바뀌지 않는다.
타입을 string|number의 유니온 타입으로 범위를 좁히면 오류가 해결된다.
하지만 유니온 타입은 string이나 number에 비해 더 다루기 어렵다. 차라리 별도의 변수를 도입하는 게 낫다.
const id = "123";
const serial = 123;
타입이 다른 값을 다룰 때는 변수를 재사용하지 말자. 혼란을 줄 뿐이다.
다른 타입에는 별도의 변수를 사용하는 게 바람직한 이유
- 서로 관련 없는 값들을 분리한다. (id와 serial)
- 변수명을 더 구체적으로 지을 수 있다.
- 타입 추론을 향상시키며, 타입 구문이 불필요해진다.
- 타입이 좀 더 간결해진다. (string|number 대신 string과 number 사용)
- let 대신 const로 변수를 선언하게 된다.
const로 선언하면 코드가 간결해지고, 타입 체커가 타입을 추론하기에도 좋다.
재사용되는 변수와 가려지는 변수를 혼동해서는 안 된다.
const id = "123";
{
const id = 123;
}
두 id는 이름이 같지만 실제로 서로 아무런 관계가 없다. 다른 타입으로 사용되어도 문제없다.
그렇지만 사람에게 혼란을 줄 수 있기에 목적이 다른 곳에는 별도의 변수명을 사용하도록 하자.
린터 규칙을 통해 가려지는 변수를 사용하지 못하도록 하는 것도 좋다.
21. 타입 넓히기
타입스크립트는 넓히기를 통해 상수의 타입을 추론한다.
런타임에 모든 변수는 유일한 값을 가진다. 그러나 타입스크립트의 정적 분석 시점에, 변수는 가능한 값들의 집합인 타입을 가진다.
상수로 변수를 초기화할 때 타입을 명시하지 않으면 타입 체커는 타입을 결정해야 한다.
즉, 단일 값을 가지고 할당 가능한 값들의 집합을 유추하는 것이다. (넓히기)
interface Vector3 {
x: number;
y: number;
z: number;
}
function getComponent(vector: Vector3, axis: "x" | "y" | "z") {
return vector[axis];
}
let x = "x"; // 타입이 string
let vec = { x: 10, y: 20, z: 30 };
getComponent(vec, x);
// 'string' 형식의 인수는 '"x" | "y" | "z"' 형식의 매개변수에 할당될 수 없습니다.
실행은 잘 되지만 편집기에서는 오류가 팔생한다. 타입 넓히기의 과정은 상당히 모호하다.
정보가 충분하지 않다면 어떤 타입으로 추론되어야 하는지 알 수 없다.
넓히기 과정을 제어하는 방법을 보자.
1. const 사용하기
let 대신 const를 사용하면 더 좁은 타입이 된다. 위에서 const로 바꾸면 오류가 해결된다.
const x = "x"; // 타입이 "x"
x는 재할당될 수 없기 때문에 타입스크립트가 더 좁은 타입으로 추론할 수 있다.
그러나 const는 만능이 아니다. 객체와 배열의 경우엔 여전히 문제가 있다.
객체의 경우 넓히기 알고리즘은 각 요소를 let으로 할당된 것처럼 다룬다.
const v = { x: 1 }; // 타입이 {x: number}
타입 추론의 강도를 직접 제어하려면 타입스크립트의 기본 동작을 재정의해야 한다.
2. 명시적 타입 구문 제공하기
const v: { x: 1 | 3 | 5 } = { x: 1 }; // 타입이 {x: 1|3|5}
2. 타입 체커에 추가적인 문맥 제공하기
에를 들어, 객체 리터럴을 정의하지 않고 함수의 매개변수로 값을 바로 전달할 수 있다.
const v = { x: 1 };
const logV = (v: { x: 1 | 3 | 5 }) => {
console.log(v);
};
logV(v); // 오류 발생
logV({ x: 1 }); // 정상
3. const 단언문 사용하기
const 단언문은 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; }
const a1 = [1, 2, 3]; // number[]
const a2 = [1, 2, 3] as const; // readonly [1, 2, 3]
값 뒤에 as const를 작성하면 타입스크립트는 최대한 좁은 타입으로 추론한다.
v3, a2에서는 넓히기가 아예 동작하지 않았다.
22. 타입 좁히기
타입 넓히기의 반대다. 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정이다.
1. 분기문 사용
가장 일반적인 예시는 null 체크다.
const el = document.getElementById("foo"); // 타입이 HTMLElement | null
if (el) { // null을 제외하는 타입 좁히기
el; // 타입이 HTMLElement
} else {
el; // 타입이 null
}
분기문에서 예외를 던지거나 함수를 반환하여 타입을 좁힐 수도 있다.
const el = document.getElementById("foo"); // 타입이 HTMLElement | null
if (!el) throw new Error();
el; // 타입이 HTMLElement
하지만 실수를 저지를 수도 있다.
const el = document.getElementById("foo"); // 타입이 HTMLElement | null
if (typeof el === "object") {
el; // 타입이 HTMLElement | null
} // typeof null은 "object"
function foo(x?: string | number | null) {
if (!x) {
x; // 타입이 string | number | null | undefined
}
} // ''와 0 모두 false가 되기 때문에 타입이 좁혀지지 않았다.
2. instanceof 사용
function contains(text: string, search: string | RegExp) {
if (search instanceof RegExp) {
search; // 타입이 RegExp
}
search; // 타입이 string
}
3. 속성 체크
interface A { a: number; }
interface B { b: number; }
function pickAB(ab: A | B) {
if ("a" in ab) {
ab; // 타입이 A
} else {
ab; // 타입이 B
}
ab; // 타입이 A | B
}
4. 내장 함수 사용
Array.isArray의 예시를 보자.
function contains(text: string, terms: string | string[]) {
const termList = Array.isArray(terms) ? terms : [terms];
termList; // 타입이 string[]
}
5. 명시적 태그 (tagged union / discriminated union)
interface UploadEvent {
type: "upload";
filename: string;
}
interface DownloadEvent {
type: "download";
filename: string;
}
type AppEvent = UploadEvent | DownloadEvent;
function handleEvent(e: AppEvent) {
switch (e.type) {
case "download":
e; // 타입이 DownloadEvent
break;
case "upload":
e; // 타입이 UploadEvent
break;
}
}
6. 커스텀 함수 (사용자 정의 타입 가드)
function isInputElement(el: HTMLElement): el is HTMLInputElement {
return "value" in el;
}
function getElementContent(el: HTMLElement) {
if (isInputElement(el)) {
el; // 타입이 HTMLInputElement
}
el; // 타입이 HTMLElement
}
const Alphabets = ["a", "b", "c", "d"];
const chars = ["a", "b"]
.map((who) => Alphabets.find((n) => n === who))
.filter((who) => who !== undefined);
// const chars: (string | undefined)[]
function isDefined<T>(x: T | undefined): x is T {
return x !== undefined;
}
const chars = ["a", "b"]
.map((who) => Alphabets.find((n) => n === who))
.filter(isDefined);
// const chars: string[]
23. 한꺼번에 객체 생성하기
객체를 생성할 때는 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리하다.
const pt = {};
pt.x = 3; // '{}' 형식에 'x' 속성이 없습니다.
pt.y = 4; // '{}' 형식에 'y' 속성이 없습니다.
interface Point { x: number; y: number; }
const pt: Point = {}; // '{}' 형식에 'Point' 형식의 x, y 속성이 없습니다.
pt.x = 3;
pt.y = 3;
const pt = { x: 3, y: 4 }; // 정상
객체를 반드시 제각각 나눠서 만들어야 한다면 타입 단언문(as)을 사용할 수 있다.
const pt = {} as Point;
pt.x = 3;
pt.y = 3;
그래도 한꺼번에 만드는 게 더 낫다.
const pt: Point = { x: 3, y: 3 };
안전한 타입으로 속성을 추가하려면 객체 전개연산자를 사용하자.
const pt = { x: 3, y: 3 };
const id = { name: "abc" };
const namedPoint = {};
Object.assign(namedPoint, pt, id);
namedPoint.name; // '{}' 형식에 'name' 속성이 없습니다.
const namedPoint = { ...pt, ...id };
namedPoint.name // 정상
객체 전개 연산자를 사용하면 타입 걱정 없이 필드 단위로 객체를 생성할 수도 있다.
const pt0 = {};
const pt1 = { ...pt0, x: 3 };
const pt: Point = { ...pt1, y: 4 }; // 정상
안전한 방식으로 조건부 속성을 추가하려면, null 또는 {}으로 객체 전개를 사용하면 된다.
declare let hadMiddle: boolean;
const firstLast = { first: "Harry", last: "Truman" };
const predisent = { ...firstLast, ...(hadMiddle ? { middle: "S" } : {}) };
// const predisent: {
// middle?: string | undefined;
// first: string;
// last: string;
// };
객체나 배열을 변환해서 새로운 객체나 배열을 생성하고 싶을 수 있다.
이런 경우 루프 대신, 내장된 함수형 기법 또는 Lodash 같은 유틸 라이브러리를 사용하는 것이 '한꺼번에 객체 생성하기' 관점에서 옳다.