TypeScript 실전 팁 — 타입 잘 쓰는 법 12가지
any 없이 살아남기. 유니온, 제네릭, 타입 가드, infer 등 TypeScript를 제대로 쓰는 실전 패턴 12가지.

TypeScript를 쓰면서 any를 남발하고 있다면, 타입 시스템을 쓰는 의미가 반감된다. 반대로 타입을 너무 정교하게 짜면 코드보다 타입이 더 복잡해진다. 적당한 선을 찾는 게 관건인데, 여기서 실무에서 자주 쓰이는 패턴 12가지를 정리한다.
1. as const로 리터럴 타입 잠그기
// ❌ string[]으로 추론됨
const ROLES = ["admin", "user", "guest"];
// ✅ readonly ["admin", "user", "guest"]로 추론됨
const ROLES = ["admin", "user", "guest"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "user" | "guest"
설정값이나 상수 배열을 정의할 때 as const를 붙이면 리터럴 타입으로 고정된다. 별도의 유니온 타입을 선언할 필요 없이 배열에서 타입을 뽑아 쓸 수 있다. 객체에도 적용 가능해서, 설정 객체의 값까지 리터럴로 좁힐 수 있다. 런타임에는 아무 영향 없고 순수하게 타입 레벨에서만 동작한다는 점도 매력이다.
2. Record보다 구체적인 객체 타입
// ❌ 너무 넓음
type Config = Record<string, unknown>;
// ✅ 실제 구조를 반영
type Config = {
apiUrl: string;
timeout: number;
retries: number;
};
Record<string, any>는 모든 키를 허용하니까 오타를 잡지 못한다. config.timout이라고 써도 에러가 안 난다. 가능한 한 정확한 키를 명시하자. 동적 키가 정말 필요한 경우에만 Record를 쓰되, 값 타입이라도 구체적으로 지정하는 게 좋다.
3. 유니온 + never로 완전성 검사
type Status = "idle" | "loading" | "success" | "error";
function handleStatus(status: Status) {
switch (status) {
case "idle": return "대기";
case "loading": return "로딩 중";
case "success": return "완료";
case "error": return "오류";
default: {
const _exhaustive: never = status;
return _exhaustive;
}
}
}
나중에 Status에 "retrying" 같은 값을 추가하면, switch문에서 처리하지 않은 경우 컴파일 에러가 난다. 유니온 타입에 값을 추가할 때 관련 핸들러를 빠뜨리는 실수를 방지해준다. 단순하지만 효과가 상당히 크다. 코드베이스가 커질수록 이 패턴의 가치가 올라간다 — 유니온에 새 값을 추가했을 때 "어디를 고쳐야 하지?"를 컴파일러가 알려주니까.
4. 타입 가드 함수
// ❌ 타입 단언
const user = data as User;
// ✅ 타입 가드
function isUser(data: unknown): data is User {
return (
typeof data === "object" &&
data !== null &&
"id" in data &&
"name" in data
);
}
if (isUser(data)) {
// 여기서 data는 User 타입
console.log(data.name);
}
as로 타입을 단언하면 런타임에서 실제로 그 타입인지 확인하지 않는다. 서버에서 이상한 데이터가 내려와도 컴파일러는 아무 말 안 한다. 타입 가드 함수(is 키워드)를 쓰면 런타임 검증과 타입 좁히기가 동시에 된다.
API 응답 처리에 특히 유용하다. 외부에서 들어오는 데이터는 타입이 보장되지 않으니까, 가드 함수로 걸러내는 습관을 들이면 런타임 에러가 확 줄어든다.
5. Discriminated Union
type ApiResponse =
| { status: "success"; data: User[] }
| { status: "error"; message: string }
| { status: "loading" };
function render(res: ApiResponse) {
if (res.status === "success") {
// res.data 접근 가능
} else if (res.status === "error") {
// res.message 접근 가능
}
}
공통 필드(status)로 유니온 멤버를 구분할 수 있으면, TypeScript가 자동으로 타입을 좁혀준다. API 응답, 이벤트 처리, 상태 관리 등에서 패턴 매칭처럼 쓸 수 있다.
이 패턴의 핵심은 "각 상태마다 사용 가능한 필드가 다르다"는 걸 타입으로 표현하는 거다. success 상태에서만 data가 존재하고, error 상태에서만 message가 존재한다는 걸 컴파일러가 알고 있다. 존재하지 않는 필드에 접근하려 하면 에러가 난다.
6. 제네릭은 필요할 때만
// ❌ 불필요한 제네릭
function getFirst<T>(arr: T[]): T {
return arr[0];
}
// ✅ 제네릭이 필요한 경우 — 입력과 출력의 관계를 표현
function groupBy<T, K extends string>(
items: T[],
keyFn: (item: T) => K
): Record<K, T[]> {
// ...
}
제네릭은 입력과 출력 사이의 관계를 표현할 때 쓰는 거다. "이 함수에 A 타입을 넣으면 A 타입이 나온다"는 관계가 있을 때 의미가 있다. 단순히 "아무 타입이나 받겠다"가 목적이면 제네릭보다 유니온이나 오버로드가 나을 수 있다. 제네릭을 반사적으로 추가하지 말고, 정말 타입 관계를 표현해야 하는지 먼저 생각하자.
7. infer로 타입 추출
// 프로미스 내부 타입 추출
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>; // number
// 함수 반환 타입 추출 (ReturnType 내부 구현)
type Return<T> = T extends (...args: never[]) => infer R ? R : never;
infer는 조건부 타입 안에서 타입 변수를 "캡처"한다. 라이브러리 타입에서 원하는 부분만 꺼내 쓸 때 유용하다. 사실 TypeScript에 내장된 ReturnType, Parameters, Awaited 같은 유틸리티 타입도 내부적으로 infer를 쓰고 있다. 직접 쓸 일이 자주 있지는 않지만, 라이브러리 타입을 읽거나 커스텀 유틸리티 타입을 만들 때 알아야 하는 개념이다.
8. satisfies 연산자
TypeScript 4.9에 추가된 satisfies는 타입을 만족하는지 검증하면서도 추론된 타입을 유지한다.
type ColorMap = Record<string, string | number[]>;
// as를 쓰면 원래 타입 정보가 사라짐
// satisfies는 검증만 하고 추론된 타입을 보존
const colors = {
red: "#ff0000",
green: [0, 255, 0],
} satisfies ColorMap;
colors.red.toUpperCase(); // ✅ string으로 추론됨
colors.green.map(v => v); // ✅ number[]로 추론됨
as를 쓰면 원래 타입 정보가 날아가서 colors.red가 string | number[]가 된다. satisfies는 "이 객체가 ColorMap 형태를 만족하는지 확인하되, 각 필드의 실제 타입은 그대로 유지해줘"라는 뜻이다. 설정 객체를 정의할 때 특히 유용한 패턴이다.
9. Readonly와 불변성
type User = {
readonly id: string;
name: string;
settings: Readonly<{
theme: "light" | "dark";
language: string;
}>;
};
변경되면 안 되는 필드에 readonly를 붙이자. 런타임에서 방어하는 게 아니라 실수를 컴파일 타임에 잡는 용도다. 함수 인자로 배열을 받을 때 readonly T[]로 선언하면 함수 내부에서 원본을 변경하는 실수를 방지할 수 있다.
// 원본 배열을 수정할 수 없음
function getTotal(items: readonly number[]): number {
// items.push(1); // 컴파일 에러
return items.reduce((a, b) => a + b, 0);
}
특히 React 컴포넌트에서 props를 Readonly로 선언하면, props를 직접 수정하는 버그를 방지할 수 있다.
10. 템플릿 리터럴 타입
type EventName = "click" | "hover" | "focus";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onHover" | "onFocus"
type CSSUnit = `${number}${"px" | "rem" | "em" | "%"}`;
const width: CSSUnit = "100px"; // ✅
문자열 패턴을 타입으로 표현할 수 있다. API 경로, CSS 값, 이벤트 이름 등 정해진 패턴의 문자열에 쓰면 오타를 잡아준다. string으로 두면 아무 문자열이나 들어갈 수 있는데, 템플릿 리터럴 타입으로 패턴을 제한하면 유효하지 않은 값이 들어가는 걸 컴파일 타임에 막을 수 있다.
API 경로 타이핑에 쓰면 효과가 좋다:
type ApiPath = `/api/${"users" | "posts" | "comments"}`;
11. 유틸리티 타입 조합
// 일부 필드만 필수로 만들기
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
// 생성 시에는 id 없이, 조회 시에는 id 포함
type CreateUser = Omit<User, "id" | "createdAt">;
type UserResponse = RequireFields<User, "id" | "createdAt">;
Pick, Omit, Partial, Required 같은 내장 유틸리티 타입을 조합하면 기존 타입에서 파생 타입을 만들기 쉽다. 같은 엔티티의 "생성용", "수정용", "응답용" 타입을 각각 선언하지 말고 하나의 베이스 타입에서 파생하자. 타입이 중복되면 나중에 필드를 추가할 때 빠뜨리는 실수가 생긴다.
이 패턴을 쓰면 하나의 소스 타입만 수정하면 파생 타입이 전부 따라서 바뀌니까 유지보수가 편해진다.
12. 타입과 런타임 검증 통합 (Zod)
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive(),
});
// 스키마에서 타입을 추출
type User = z.infer<typeof UserSchema>;
// 런타임 검증
const result = UserSchema.safeParse(apiResponse);
if (result.success) {
// result.data는 User 타입
}
Zod 같은 스키마 라이브러리를 쓰면 타입 정의와 런타임 검증을 하나의 스키마로 통합할 수 있다. 타입과 검증 로직이 따로 놀면서 싱크가 안 맞는 문제를 원천적으로 방지한다.
API 경계에서 특히 효과적이다. 외부에서 들어오는 데이터를 Zod로 검증하면, 검증을 통과한 시점부터 타입이 보장된다. 프론트엔드와 백엔드 사이, 서버와 외부 API 사이처럼 "신뢰할 수 없는 데이터"가 들어오는 지점에 배치하면 된다.
Zod 외에도 Valibot(더 가벼움), Yup(오래된 대안), ArkType(가장 빠른 성능) 같은 선택지가 있다. 번들 사이즈가 걱정이면 Valibot을 고려해볼 만하다.
TypeScript 타입 시스템은 깊이 들어가면 끝이 없는데, 실무에서 자주 마주치는 패턴은 이 정도로 거의 커버된다. 핵심은 "타입이 코드의 의도를 표현하게 하라"는 거다. 타입을 잘 쓰면 문서화, 에러 방지, 리팩토링 안전성이 따라온다. 반대로 any로 도배하면 JavaScript를 쓰는 거랑 다를 게 없다.
한 가지 더 팁을 추가하자면, TypeScript를 처음 도입하는 프로젝트에서 이 12가지를 한꺼번에 적용하려고 하면 오히려 역효과가 날 수 있다. 처음에는 기본적인 타입 힌트(string, number, 인터페이스)만 쓰다가, 코드가 복잡해지면서 유니온, 제네릭, 타입 가드를 하나씩 도입하는 게 자연스럽다.
타입은 코드의 자기 문서화 도구이기도 하다. 잘 작성된 타입 정의는 JSDoc 주석이나 별도 문서보다 더 정확하고, 항상 코드와 싱크가 맞는다. 새 팀원이 들어왔을 때 코드를 이해하는 시간이 줄어들고, 6개월 뒤에 자기 코드를 다시 볼 때도 의도를 빠르게 파악할 수 있다.