DevFinance
테크·10 min read

TypeScript 실무 패턴 2026 — 바로 쓰는 고급 패턴 완전 가이드

실무에서 즉시 활용할 수 있는 TypeScript 고급 패턴을 코드 예제와 함께 완전 정리했습니다. Discriminated Union, 브랜드 타입, Zod 검증, 제네릭 패턴, 유틸리티 타입 활용까지 포함합니다.

TypeScript의 힘: 런타임 에러를 컴파일 타임에 잡는다

TypeScript는 JavaScript에 정적 타입을 추가해 버그를 배포 전에 잡고, 대규모 코드베이스의 유지보수성을 높입니다. 기본 사용법을 넘어 실무에서 차이를 만드는 고급 패턴을 정리했습니다.


1. Discriminated Union — API 응답 처리의 핵심

태그(판별자)를 이용해 타입을 좁히는 패턴입니다. API 응답, 상태 관리, 이벤트 시스템에서 가장 유용합니다.

type ApiResponse<T> =
  | { status: "success"; data: T }
  | { status: "error"; error: string; statusCode: number }
  | { status: "loading" };

function handleResponse<T>(res: ApiResponse<T>): T | null {
  switch (res.status) {
    case "success":
      return res.data;       // T 타입 자동 추론
    case "error":
      console.error(`${res.statusCode}: ${res.error}`);
      return null;
    case "loading":
      return null;
  }
}

React 상태 관리에 적용

type FetchState<T> =
  | { type: "idle" }
  | { type: "loading" }
  | { type: "success"; data: T }
  | { type: "error"; message: string };

function UserProfile({ userId }: { userId: string }) {
  const [state, setState] = useState<FetchState<User>>({ type: "idle" });

  // 렌더링 시 각 상태를 완전히 처리
  if (state.type === "loading") return <Spinner />;
  if (state.type === "error") return <Error message={state.message} />;
  if (state.type === "success") return <Profile user={state.data} />;
  return null;
}

2. 브랜드 타입 — 의미 있는 원시 타입

같은 string이나 number라도 서로 다른 의미로 구분합니다.

// 브랜드 타입 정의
type UserId = string & { __brand: "UserId" };
type PostId = string & { __brand: "PostId" };
type Amount = number & { __brand: "Amount" };  // 원화 금액
type Percent = number & { __brand: "Percent" }; // 0~100

// 생성 함수 (유효성 검사 포함)
function createAmount(value: number): Amount {
  if (value < 0) throw new Error("금액은 0 이상이어야 합니다.");
  return value as Amount;
}

function createPercent(value: number): Percent {
  if (value < 0 || value > 100) throw new Error("퍼센트는 0~100이어야 합니다.");
  return value as Percent;
}

// 사용 — 타입 혼용 방지
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = "abc" as UserId;
const postId = "xyz" as PostId;

getUser(userId);  // OK
getUser(postId);  // 컴파일 에러 — PostId를 UserId로 넘길 수 없음

3. satisfies 연산자 — 타입 체크 + 추론 유지

// 문제: 명시적 타입 주석은 리터럴 추론을 잃음
const routes: Record<string, string> = {
  home: "/",
  about: "/about",
};
routes.home;  // string 타입 (리터럴 "/" 아님)

// 해결: satisfies로 체크하면서 리터럴 유지
const routes = {
  home: "/",
  about: "/about",
  blog: "/blog",
} satisfies Record<string, string>;

routes.home;  // "/" 리터럴 타입 유지
// routes.invalid  // 컴파일 에러 (Record 타입 준수 검증)

설정 객체에 활용

type LogLevel = "debug" | "info" | "warn" | "error";

const config = {
  logLevel: "info",
  maxRetries: 3,
  timeout: 5000,
} satisfies {
  logLevel: LogLevel;
  maxRetries: number;
  timeout: number;
};

// config.logLevel은 "info" 리터럴 (LogLevel 아님)
// 하지만 "invalid" 를 넣으면 컴파일 에러

4. Template Literal Types — 문자열 타입 안전성

// CSS 단위
type CSSUnit = `${number}${"px" | "rem" | "em" | "%"}`;
const padding: CSSUnit = "16px";   // OK
const margin: CSSUnit = "1.5rem";  // OK
// const invalid: CSSUnit = "16vw"; // 에러

// 이벤트 이름 패턴
type EventName<T extends string> = `on${Capitalize<T>}`;
type ButtonEvents = EventName<"click" | "hover" | "focus">;
// "onClick" | "onHover" | "onFocus"

// API 엔드포인트 타입
type ApiEndpoint = `/api/${string}`;
function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi("/api/users");    // OK
// callApi("/users");     // 에러

5. 조건부 타입 — 입력에 따른 출력 타입

type Endpoint = "/users" | "/posts" | "/comments";
type User = { id: string; name: string };
type Post = { id: string; title: string };
type Comment = { id: string; content: string };

type ResponseOf<E extends Endpoint> =
  E extends "/users" ? User[] :
  E extends "/posts" ? Post[] :
  E extends "/comments" ? Comment[] :
  never;

async function fetchApi<E extends Endpoint>(
  endpoint: E
): Promise<ResponseOf<E>> {
  const res = await fetch(endpoint);
  return res.json();
}

const users = await fetchApi("/users");    // User[] 타입 자동 추론
const posts = await fetchApi("/posts");    // Post[] 타입 자동 추론

유용한 유틸리티 타입 활용

// 선택적 → 필수 변환
type Required<T> = { [K in keyof T]-?: T[K] };

// 깊은 Partial
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// 특정 키만 선택
type Pick<T, K extends keyof T> = { [P in K]: T[P] };

// Nullable 추가
type Nullable<T> = { [K in keyof T]: T[K] | null };

6. const Assertion — 리터럴 타입 추출

// 배열에서 유니온 타입 추출
const ROLES = ["admin", "user", "moderator"] as const;
type Role = (typeof ROLES)[number];
// "admin" | "user" | "moderator"

// 객체에서 값 타입 추출
const STATUS = {
  ACTIVE: "active",
  INACTIVE: "inactive",
  PENDING: "pending",
} as const;

type Status = (typeof STATUS)[keyof typeof STATUS];
// "active" | "inactive" | "pending"

// 함수에서 사용
function setRole(role: Role) { /* ... */ }
setRole("admin");    // OK
// setRole("guest"); // 에러

7. Zod — 런타임 + 컴파일 타임 동시 검증

import { z } from "zod";

// 스키마 정의 한 번 → TypeScript 타입 자동 추론
const UserSchema = z.object({
  name: z.string().min(1, "이름은 필수입니다."),
  email: z.string().email("이메일 형식이 올바르지 않습니다."),
  age: z.number().int().min(0).max(150),
  role: z.enum(["admin", "user", "moderator"]),
});

type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "user" | "moderator" }

// API 핸들러에서 검증
export async function POST(req: Request) {
  const body = await req.json();
  const result = UserSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      { error: { code: "VALIDATION_ERROR", details: result.error.flatten() } },
      { status: 422 }
    );
  }

  // result.data는 완전히 타입 안전
  const user: User = result.data;
}

복잡한 스키마 구성

const PaginationSchema = z.object({
  cursor: z.string().optional(),
  limit: z.number().int().min(1).max(100).default(20),
});

const PostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  tags: z.array(z.string()).max(5),
  publishedAt: z.string().datetime().optional(),
});

// 스키마 합성
const CreatePostSchema = PostSchema.extend({
  authorId: z.string().uuid(),
});

8. 제네릭 컴포넌트 — 타입 안전한 재사용

// 타입 안전한 범용 리스트 컴포넌트
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyState?: React.ReactNode;
}

function List<T>({
  items,
  renderItem,
  keyExtractor,
  emptyState = <p>데이터가 없습니다.</p>,
}: ListProps<T>) {
  if (items.length === 0) return <>{emptyState}</>;

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// T가 자동 추론됨
<List
  items={users}                          // T = User
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

9. Type Guard — 런타임 타입 검사를 타입 시스템과 연결

// 커스텀 Type Guard 함수
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function isNonNull<T>(value: T | null | undefined): value is T {
  return value != null;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    typeof (value as User).id === "string"
  );
}

// 활용
const results = [1, null, 3, undefined, 5];
const validNumbers = results.filter(isNonNull);  // number[] (null/undefined 제거)

const apiResponse: unknown = await fetch("/api/user").then(r => r.json());
if (isUser(apiResponse)) {
  console.log(apiResponse.name);  // User 타입으로 안전하게 접근
}

10. Builder 패턴 — 복잡한 객체 생성

class QueryBuilder<T extends Record<string, unknown>> {
  private filters: Array<(item: T) => boolean> = [];
  private sortField?: keyof T;
  private sortOrder: "asc" | "desc" = "asc";
  private limitCount?: number;

  where(fn: (item: T) => boolean): this {
    this.filters.push(fn);
    return this;
  }

  sortBy(field: keyof T, order: "asc" | "desc" = "asc"): this {
    this.sortField = field;
    this.sortOrder = order;
    return this;
  }

  limit(count: number): this {
    this.limitCount = count;
    return this;
  }

  execute(data: T[]): T[] {
    let result = data.filter((item) =>
      this.filters.every((fn) => fn(item))
    );

    if (this.sortField) {
      const field = this.sortField;
      result = result.sort((a, b) => {
        const aVal = a[field];
        const bVal = b[field];
        const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
        return this.sortOrder === "asc" ? cmp : -cmp;
      });
    }

    if (this.limitCount !== undefined) {
      result = result.slice(0, this.limitCount);
    }

    return result;
  }
}

// 사용
const result = new QueryBuilder<User>()
  .where((u) => u.age >= 20)
  .where((u) => u.role === "developer")
  .sortBy("name")
  .limit(10)
  .execute(users);

실무 적용 요약

패턴주요 사용 사례
Discriminated UnionAPI 응답, 상태 관리, 이벤트 핸들링
브랜드 타입ID, 금액, 퍼센트 등 의미 있는 원시 타입
satisfies설정 객체, 라우트 맵, 컬러 팔레트
Template LiteralCSS 단위, 이벤트 이름, API 경로
조건부 타입입력에 따라 출력 타입이 달라지는 함수
const Assertion상수 배열/객체에서 유니온 타입 추출
ZodAPI 입력 검증, 폼 검증, 환경 변수 검증
제네릭 컴포넌트재사용 가능한 UI 컴포넌트
Type Guard외부 데이터 타입 좁히기
Builder 패턴복잡한 쿼리, 설정 빌더

관련 글: REST API 설계 베스트 프랙티스 · Supabase 사이드프로젝트 시작하기 · Next.js SSG 완전 가이드

DF

DevFinance 편집팀

현직 소프트웨어 엔지니어가 운영합니다. IT 업계 종사자에게 필요한 재테크·기술 정보를 공식 데이터와 실무 경험을 바탕으로 정리합니다.

더 알아보기