제가 실제로 매일 사용하는 TypeScript 트릭
Source: Dev.to

나는 React Native, Node.js, 그리고 다양한 제품군에서 몇 년 동안 TypeScript를 써 왔어요. 문서가 가르쳐 주는 것과 실제로 매일 쓰는 것 사이에는 차이가 있죠. 여기 내가 계속해서 되돌아가는 패턴들을 소개합니다.
상태 관리에 사용되는 구분된 유니온(Discriminated unions)
이 패턴은 데이터 모델링 방식을 완전히 바꿔줬어요. 존재할 수도, 안 할 수도 있는 여러 선택적 필드 대신, 각 상태를 명시적으로 정의합니다.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; message: string };
이제 TypeScript는 각 경우에 어떤 것이 가능한지 정확히 알게 됩니다. 데이터가 undefined일 수 있다는 체크를 여기저기 흩뿌릴 필요가 없어요.
직접 타입 어노테이션 대신 satisfies 사용
이 최신 기능은 꽤 유용합니다. 차이는 미묘하지만 중요한 차이를 만들죠.
const config = {
theme: "dark",
language: "en",
} satisfies Record;
satisfies를 사용하면 리터럴 타입을 잃지 않으면서 타입 검사를 할 수 있습니다. Record로 직접 어노테이션하면 구체적인 값들을 잃게 되죠. 작은 차이지만, 체이닝하거나 추론할 때 큰 차이를 만들어요.
실제로 필요한 유틸리티 타입
모두가 Partial과 Required를 알고 있죠. 내가 더 자주 쓰는 것들은 다음과 같습니다:
// 필요한 것만 골라서
type Preview = Pick;
// 필요 없는 것을 제외하고
type PublicUser = Omit;
// 특정 필드를 선택적으로 만들기
type UpdateInput = Partial> & Pick;
마지막 예시는 업데이트/패치 엔드포인트를 만들 때 계속 사용합니다.
문자열 계약을 위한 템플릿 리터럴 타입
이벤트 이름, 라우트, 혹은 문자열 기반 구조를 다룰 때 유용합니다.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = `/${string}`;
type Route = `${HttpMethod} ${Endpoint}`;
const route: Route = "GET /users"; // 유효
const bad: Route = "FETCH /users"; // 오류
런타임이 아니라 컴파일 타임에 전체 클래스의 버그를 잡아줍니다.
제네릭에서 타입을 추출하기 위한 infer
처음엔 무섭게 보이지만, 이해하고 나면 어디서든 사용하게 됩니다.
type ReturnType = T extends (...args: any[]) => infer R ? R : never;
type UnpackPromise = T extends Promise ? U : T;
UnpackPromise는 async 함수를 다루면서 await 없이도 해결된 타입이 필요할 때 계속 사용합니다.
설정 객체에 대한 const 단언
필요 없는 경우 타입이 넓어지는 것을 방지합니다.
const ROLES = ["admin", "user", "guest"] as const;
type Role = typeof ROLES[number]; // "admin" | "user" | "guest"
깨끗하고, 수동으로 중복할 필요가 없으며, 타입이 자동으로 동기화됩니다.
이것들은 전혀 이색적인 것이 아닙니다. 실제 제품을 만들 때 반복해서 마주치는 것들이죠. 많이 사용할수록 TypeScript 컴파일러가 싸워야 할 적이 아니라 팀원처럼 느껴집니다. 바로 그 변화가 추구할 가치가 있는 전환입니다.