WebPiki
tutorial

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>는 모든 키를 허용하니까 오타를 잡지 못한다. 가능한 한 정확한 키를 명시하자. 동적 키가 정말 필요한 경우에만 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 응답, 이벤트 처리, 상태 관리 등에서 패턴 매칭처럼 쓸 수 있다.

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[]> {
  // ...
}

제네릭은 입력과 출력 사이의 관계를 표현할 때 쓰는 거다. 단순히 "아무 타입이나 받겠다"가 목적이면 제네릭보다 유니온이나 오버로드가 나을 수 있다.

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는 조건부 타입 안에서 타입 변수를 "캡처"한다. 라이브러리 타입에서 원하는 부분만 꺼내 쓸 때 유용하다.

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[]로 추론됨

9. Readonly와 불변성

type User = {
  readonly id: string;
  name: string;
  settings: Readonly<{
    theme: "light" | "dark";
    language: string;
  }>;
};

변경되면 안 되는 필드에 readonly를 붙이자. 런타임에서 방어하는 게 아니라 실수를 컴파일 타임에 잡는 용도다. 함수 인자로 배열을 받을 때 readonly T[]로 선언하면 함수 내부에서 원본을 변경하는 실수를 방지할 수 있다.

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 값, 이벤트 이름 등 정해진 패턴의 문자열에 쓰면 오타를 잡아준다.

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 경계에서 특히 효과적이다.


TypeScript 타입 시스템은 깊이 들어가면 끝이 없는데, 실무에서 자주 마주치는 패턴은 이 정도로 거의 커버된다. 핵심은 "타입이 코드의 의도를 표현하게 하라"는 거다. 타입을 잘 쓰면 문서화, 에러 방지, 리팩토링 안전성이 따라온다.

#TypeScript#타입#프론트엔드#JavaScript#개발

관련 글