자바스크립트를 오래 짰으면 머릿속으로는 이미 타입을 따지고 있다. 이 변수엔 문자열이 들어오고, 저 함수는 숫자를 뱉고. 타입스크립트로 넘어온다는 건 그 머릿속 규칙을 코드로 받아 적는 일이다. 문법을 전부 외울 필요는 없고, 실무에서 손이 자주 가는 몇 가지 패턴만 몸에 붙이면 나머지는 그때그때 찾아 쓰면 된다.
그래서 여기서는 문법 나열 대신, 실제로 매일 쓰는 패턴을 상황과 함께 보여주겠다. 결과 타입까지 눈으로 확인하면 왜 이렇게 쓰는지 손에 남는다.
유니온 타입은 "이거 아니면 저거"를 그대로 적는 것이다. 어떤 값이 정해진 몇 가지 중 하나뿐일 때, 그 후보를 |로 늘어놓으면 된다. 상태값이나 모드처럼 정해진 문자열이 오는 자리에 특히 잘 맞는다.
type Status = "loading" | "success" | "error";
let state: Status = "loading";
state = "done"; // 후보에 없어서 막힌다
Status를 이렇게 적어두면 state에는 저 셋 말고는 아무것도 못 들어간다. "done"처럼 오타나 엉뚱한 값을 넣으면 컴파일 단계에서 걸린다. 자바스크립트에서 문자열 상수를 남발하다 오타 하나로 밤새우던 걸, 타입 한 줄로 끝내는 셈이다.
판별 유니온으로 상태를 통째로 모델링한다. 유니온이 진짜 힘을 내는 건 객체를 묶을 때다. 성공일 땐 데이터가, 실패일 땐 메시지가 오는 응답을 생각해보자. 공통으로 가진 status 같은 꼬리표(판별자)를 두고 경우를 나눠 적는다.
type Result =
| { status: "success"; data: string }
| { status: "error"; message: string };
이렇게 적어두면 status 값에 따라 어떤 필드가 있는지가 정해진다. 그래서 확인 없이 아무 필드나 찍으면 이렇게 막힌다.
function render(r: Result) {
console.log(r.data); // 이러면 안 된다
}
// error TS2339: Property 'data' does not exist on type 'Result'.
// Property 'data' does not exist on type '{ status: "error"; message: string; }'.
에러 메시지가 친절하다. error 쪽에는 data가 없다고 콕 집어준다. 성공일 때만 있는 필드를 실패 경우까지 뭉뚱그려 쓰려 했으니 당연하다.
좁히기를 거치면 그 안에서 타입이 딱 정해진다. 판별자를 if로 한 번 걸러주면, 그 블록 안에서 타입스크립트가 경우를 하나로 좁혀준다. 이걸 좁히기(narrowing, 타입을 더 정확한 것으로 줄이는 것)라고 부른다.
function render(r: Result) {
if (r.status === "success") {
console.log(r.data.toUpperCase()); // 여기선 data 가 보인다
} else {
console.log(r.message); // 여기선 message 가 보인다
}
}
r.status === "success"를 통과한 순간 r은 성공 객체로 좁혀져서 data가 열리고, else 쪽에서는 자동으로 실패 객체가 되어 message가 열린다. 좁히기는 typeof x === "string"이나 "key" in obj로도 걸리는데 원리는 다 같다. 조건으로 경우를 줄이면 그 안에서 타입이 선명해진다.
as const로 값을 리터럴로 못 박는다. 객체나 배열을 그냥 만들면 타입스크립트는 넉넉하게 넓혀 잡는다. 뒤에 값이 바뀔 수 있다고 보기 때문이다. 근데 안 바뀔 값이라면 as const로 못 박는 게 낫다.
const a = { role: "admin" };
// 추론 타입: { role: string }
const b = { role: "admin" } as const;
// 추론 타입: { readonly role: "admin" }
같은 코드인데 a의 role은 그냥 string이고, b의 role은 정확히 "admin"이라는 리터럴이 된다. 값을 정해진 후보로 다루고 싶을 때 as const가 그 문을 열어준다. 덤으로 전부 readonly가 붙어서 실수로 바꾸는 것도 막힌다.
const arr = [1, 2, 3] as const;
arr.push(4);
// error TS2339: Property 'push' does not exist on type 'readonly [1, 2, 3]'.
keyof로 "객체의 키"를 타입으로 다룬다. 어떤 객체의 속성 이름만 받고 싶을 때, 키 목록을 손으로 또 적지 말고 keyof로 뽑아 쓴다. 타입이 곧 문서가 되고, 키가 늘면 알아서 따라온다.
interface User { id: number; name: string; email: string; }
function getField(u: User, key: keyof User) {
return u[key];
}
getField(user, "phone");
// error TS2345: Argument of type '"phone"' is not assignable to parameter of type 'keyof User'.
keyof User는 "id" | "name" | "email"과 같다. 그래서 없는 키 "phone"을 넘기면 즉시 잡힌다. 나중에 User에 필드를 추가하면 keyof도 자동으로 따라오니 손볼 데가 없다.
유틸리티 타입으로 있는 타입을 재활용한다. 이미 만든 타입을 조금씩 변형해서 쓸 일이 많다. 이럴 때 처음부터 다시 적지 말고, 타입스크립트가 기본 제공하는 변형 도구를 쓴다.
interface User { id: number; name: string; email: string; }
type Draft = Partial<User>; // 전부 선택적으로
type Card = Pick<User, "id" | "name">; // 일부만 골라
type NoEmail = Omit<User, "email">; // 하나 빼고
type Flags = Record<string, boolean>; // 키:값 모양 찍어내기
Partial은 모든 필드를 있어도 되고 없어도 되게 풀고, Pick은 필요한 필드만 골라 뽑고, Omit은 특정 필드만 빼서 나머지를 준다. Record는 키와 값의 모양만 주면 그 형태의 객체 타입을 찍어낸다. 폼 수정 화면엔 Partial, 목록 카드엔 Pick, 이런 식으로 상황마다 골라 쓰면 같은 타입을 여러 벌 관리할 일이 사라진다.
제네릭으로 "타입을 나중에 정하는" 함수를 만든다. 어떤 타입이 들어와도 그 타입 그대로 돌려주는 함수는, 타입을 <T>라는 자리로 비워두고 부르는 쪽이 채우게 한다.
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n 은 number | undefined
const s = first(["a", "b"]); // s 는 string | undefined
T는 정해진 타입이 아니라 부를 때 결정되는 빈자리다. 숫자 배열을 넣으면 T가 number로 잡혀 결과가 number | undefined가 되고, 문자열 배열이면 string | undefined가 된다. 이 한 함수로 어떤 배열이든 첫 요소를 안전하게 꺼낸다.
사용자 정의 타입 가드로 좁히기를 직접 만든다. 기본 좁히기로 안 되는 복잡한 경우엔, 내가 직접 판별 함수를 만들어 타입스크립트에 알려줄 수 있다. 반환 타입에 a is Cat처럼 적는 게 핵심이다.
function isCat(a: Cat | Dog): a is Cat {
return "meow" in a;
}
function speak(a: Cat | Dog) {
if (isCat(a)) a.meow(); // 여기선 Cat
else a.bark(); // 여기선 Dog
}
a is Cat은 "이 함수가 true를 주면 a를 Cat으로 봐도 된다"는 약속이다. 그 덕에 if 안에서 a가 Cat으로 좁혀진다. 여기까지가 실무에서 손이 제일 자주 가는 패턴들이다. 필요할 때 이 목록을 펴서 골라 쓰면 된다. 타입은 결국 내 머릿속 규칙을 코드에 대신 기억시키는 일이고, 그 기억이 새벽에 나 대신 버그를 잡아준다.