TypeScript 고급 타입 - 타입 시스템의 진짜 힘

이런 코드를 본 적 있으신가요?

function getProperty(obj: any, key: string): any {
  return obj[key];
}

const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name");     // any 타입 반환
const typo = getProperty(user, "naaaame");  // 오타인데도 오류 없음

any를 사용하면 타입 안전성을 잃게 됩니다. 객체에서 속성을 가져올 때, 그 속성이 존재하는지, 어떤 타입인지 알 수 없죠.

저도 처음 TypeScript를 배울 때, 기본 타입만으로는 복잡한 상황을 표현하기 어렵다는 걸 느꼈습니다. 특히 라이브러리를 만들거나 재사용 가능한 컴포넌트를 작성할 때 any를 남발하게 되더라고요.

하지만 TypeScript의 고급 타입을 알게 된 후, 훨씬 더 안전하고 표현력 있는 코드를 작성할 수 있게 되었습니다.

이 글에서는 TypeScript의 고급 타입 기능을 깊이 있게 다룹니다. 단순히 문법을 나열하는 게 아니라, 왜 이런 기능이 필요한지, 어떻게 실무에서 활용하는지를 중심으로 설명합니다.

먼저, 기초부터 이해하기: 타입 시스템이란?

고급 타입을 다루기 전에, 타입 시스템의 근본 원리를 이해해야 합니다.

타입이란 무엇인가?

타입은 값이 가질 수 있는 형태와 그 값으로 할 수 있는 연산을 정의하는 것입니다.

let num: number = 42;
num.toFixed(2);      // ✅ number는 toFixed 메서드를 가지고 있음
num.toUpperCase();   // ❌ number는 toUpperCase 메서드가 없음

타입 시스템은 이런 규칙을 컴파일 시점에 검사하여, 런타임 오류를 미리 방지합니다.

왜 고급 타입이 필요한가?

기본 타입(string, number, boolean 등)만으로는 실제 프로그램의 복잡한 관계를 표현하기 어렵습니다.

예를 들어:

  • 함수의 입력 타입에 따라 출력 타입이 달라진다면?
  • 객체의 모든 속성을 선택적으로 만들고 싶다면?
  • 여러 타입 중 공통 속성만 추출하고 싶다면?

이런 상황에서 고급 타입이 필요합니다.

1. Generics (제네릭) - 타입을 매개변수화하기

왜 Generics가 필요한가?

함수를 작성할 때, 여러 타입에 대해 동일한 로직을 적용하고 싶을 때가 있습니다.

❌ 문제 상황: any 사용

function identity(arg: any): any {
  return arg;
}

const result = identity("hello");  // any 타입
// 타입 정보를 잃어버림

any를 사용하면 타입 안전성을 잃습니다.

✅ 해결: Generics 사용

function identity<T>(arg: T): T {
  return arg;
}

const result = identity<string>("hello");     // string 타입
const num = identity<number>(42);             // number 타입
const auto = identity("hello");               // 타입 추론: string

Generics는 타입을 매개변수처럼 전달할 수 있게 해줍니다.

Generics의 기본 문법

// 함수 제네릭
function func<T>(arg: T): T {
  return arg;
}

// 인터페이스 제네릭
interface Box<T> {
  value: T;
}

const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };

// 클래스 제네릭
class Container<T> {
  private value: T;

  constructor(value: T) {
    this.value = value;
  }

  getValue(): T {
    return this.value;
  }
}

const stringContainer = new Container<string>("hello");
const numberContainer = new Container<number>(42);

활용: 배열 메서드 구현

JavaScript의 Array.prototype.map을 직접 구현해봅시다.

function map<T, U>(array: T[], callback: (item: T) => U): U[] {
  const result: U[] = [];
  for (const item of array) {
    result.push(callback(item));
  }
  return result;
}

const numbers = [1, 2, 3, 4, 5];
const doubled = map(numbers, (n) => n * 2);        // number[]
const strings = map(numbers, (n) => `${n}`);       // string[]

왜 이게 좋을까요?

  • T는 입력 배열의 타입
  • U는 콜백 함수가 반환하는 타입
  • 타입 안전성을 유지하면서 여러 타입에 대해 동작

Generic Constraints (제약 조건)

제네릭 타입에 제약을 걸 수 있습니다.

// ❌ 문제: 모든 타입을 받으면 length가 없을 수 있음
function getLength<T>(arg: T): number {
  return arg.length;  // ❌ 오류: T에 length가 없을 수 있음
}

// ✅ 해결: length 속성이 있는 타입만 받기
interface HasLength {
  length: number;
}

function getLength<T extends HasLength>(arg: T): number {
  return arg.length;  // ✅ T는 반드시 length를 가짐
}

getLength("hello");           // ✅ string은 length를 가짐
getLength([1, 2, 3]);         // ✅ array는 length를 가짐
getLength({ length: 10 });    // ✅ 객체에 length가 있음
getLength(42);                // ❌ 오류: number는 length가 없음

활용: API 응답 타입

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  id: number;
  name: string;
  price: number;
}

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

async function fetchProduct(id: number): Promise<ApiResponse<Product>> {
  const response = await fetch(`/api/products/${id}`);
  return response.json();
}

// 사용
const userResponse = await fetchUser(1);
console.log(userResponse.data.name);   // User 타입으로 추론

const productResponse = await fetchProduct(1);
console.log(productResponse.data.price); // Product 타입으로 추론

왜 이게 유용한가?

하나의 ApiResponse 인터페이스로 다양한 API 응답을 타입 안전하게 처리할 수 있습니다.

2. Keyof Type Operator - 객체 키를 타입으로

keyof 연산자는 객체 타입의 모든 키를 유니온 타입으로 추출합니다.

기본 사용법

interface User {
  id: number;
  name: string;
  email: string;
}

type UserKeys = keyof User;  // "id" | "name" | "email"

const key1: UserKeys = "id";      // ✅
const key2: UserKeys = "name";    // ✅
const key3: UserKeys = "age";     // ❌ 오류: "age"는 User의 키가 아님

활용: 타입 안전한 getProperty

서두에서 봤던 any를 사용한 코드를 개선해봅시다.

❌ 문제 코드

function getProperty(obj: any, key: string): any {
  return obj[key];
}

const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name");      // any 타입
const typo = getProperty(user, "naaaame");   // 오타인데도 오류 없음

✅ 개선: keyof 사용

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name");      // string 타입
const age = getProperty(user, "age");        // number 타입
const typo = getProperty(user, "naaaame");   // ❌ 컴파일 오류!

어떻게 동작하는가?

  1. K extends keyof T: KT의 키 중 하나여야 함
  2. T[K]: T 객체의 K 속성의 타입

Before & After 비교

❌ JavaScript (런타임 오류)

function updateUser(user, key, value) {
  user[key] = value;
}

const user = { name: "Alice", age: 30 };
updateUser(user, "name", "Bob");        // ✅
updateUser(user, "age", "30");          // ⚠️ age는 number인데 string을 넣음
updateUser(user, "address", "Seoul");   // ⚠️ address라는 키는 없음

✅ TypeScript (컴파일 시점에 오류 발견)

function updateUser<T, K extends keyof T>(user: T, key: K, value: T[K]): void {
  user[key] = value;
}

const user = { name: "Alice", age: 30 };
updateUser(user, "name", "Bob");        // ✅
updateUser(user, "age", 30);            // ✅
updateUser(user, "age", "30");          // ❌ 오류: string은 number에 할당 불가
updateUser(user, "address", "Seoul");   // ❌ 오류: "address"는 user의 키가 아님

3. Mapped Types - 타입 변환하기

Mapped Types는 기존 타입을 변환하여 새로운 타입을 만드는 기능입니다.

기본 문법

type Mapped<T> = {
  [K in keyof T]: T[K];
};

이 패턴을 이용해 다양한 유틸리티 타입을 만들 수 있습니다.

실전 예제 1: Partial - 모든 속성을 선택적으로

interface User {
  id: number;
  name: string;
  email: string;
}

// 수동으로 작성하면...
interface PartialUser {
  id?: number;
  name?: string;
  email?: string;
}

// Mapped Type으로 자동 생성
type Partial<T> = {
  [K in keyof T]?: T[K];
};

type PartialUser2 = Partial<User>;
// { id?: number; name?: string; email?: string; }

왜 유용한가?

업데이트 API에서 일부 필드만 수정할 때 유용합니다.

function updateUser(id: number, updates: Partial<User>): void {
  // updates는 User의 일부 속성만 포함 가능
}

updateUser(1, { name: "Bob" });              // ✅
updateUser(1, { email: "bob@example.com" }); // ✅
updateUser(1, { name: "Bob", age: 30 });     // ❌ 오류: age는 User에 없음

실전 예제 2: Readonly - 모든 속성을 읽기 전용으로

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

interface Config {
  apiUrl: string;
  timeout: number;
}

const config: Readonly<Config> = {
  apiUrl: "https://api.example.com",
  timeout: 5000
};

config.timeout = 3000;  // ❌ 오류: readonly 속성은 수정 불가

실전 예제 3: Pick - 특정 속성만 선택

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

// 민감한 정보를 제외한 공개 프로필
type PublicProfile = Pick<User, "id" | "name">;
// { id: number; name: string; }

const profile: PublicProfile = {
  id: 1,
  name: "Alice"
  // email과 password는 없음
};

실전 예제 4: Omit - 특정 속성 제외

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

// 비밀번호를 제외한 안전한 사용자 정보
type SafeUser = Omit<User, "password">;
// { id: number; name: string; email: string; }

function displayUser(user: SafeUser): void {
  console.log(user.name);
  console.log(user.password);  // ❌ 오류: password는 SafeUser에 없음
}

실전 예제 5: Record - 키-값 객체 타입 생성

type Record<K extends string | number | symbol, T> = {
  [P in K]: T;
};

// 국가별 환율 정보
type Currency = "USD" | "EUR" | "KRW" | "JPY";
type ExchangeRates = Record<Currency, number>;

const rates: ExchangeRates = {
  USD: 1.0,
  EUR: 0.85,
  KRW: 1200,
  JPY: 110
};

// ✅ 모든 통화가 포함됨
// ❌ 하나라도 빠지면 오류

활용:

// API 엔드포인트 맵핑
type Endpoint = "users" | "products" | "orders";
type ApiEndpoints = Record<Endpoint, string>;

const endpoints: ApiEndpoints = {
  users: "/api/users",
  products: "/api/products",
  orders: "/api/orders"
};

function fetchData(endpoint: Endpoint) {
  return fetch(endpoints[endpoint]);
}

4. Conditional Types - 조건부 타입

Conditional Types는 조건에 따라 다른 타입을 반환합니다.

기본 문법

T extends U ? X : Y

TU에 할당 가능하면 X, 아니면 Y

실전 예제 1: 타입 필터링

// number 타입만 추출
type ExtractNumbers<T> = T extends number ? T : never;

type Test1 = ExtractNumbers<string>;          // never
type Test2 = ExtractNumbers<number>;          // number
type Test3 = ExtractNumbers<string | number>; // number

실전 예제 2: 함수 반환 타입 추론

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser() {
  return { id: 1, name: "Alice" };
}

type User = ReturnType<typeof getUser>;
// { id: number; name: string; }

왜 유용한가?

함수의 반환 타입을 자동으로 추론하여, 타입을 중복 정의하지 않아도 됩니다.

❌ 중복 정의

interface User {
  id: number;
  name: string;
}

function getUser(): User {
  return { id: 1, name: "Alice" };
}

// 타입을 두 번 정의함

✅ 자동 추론

function getUser() {
  return { id: 1, name: "Alice" };
}

type User = ReturnType<typeof getUser>;
// 타입을 한 번만 정의

실전 예제 3: Nullable 타입 제거

type NonNullable<T> = T extends null | undefined ? never : T;

type Nullable = string | number | null | undefined;
type NonNull = NonNullable<Nullable>;  // string | number

실전 예제 4: Promise 언래핑

type Awaited<T> = T extends Promise<infer U> ? U : T;

type AsyncString = Promise<string>;
type SyncString = Awaited<AsyncString>;  // string

async function fetchUser(): Promise<{ id: number; name: string }> {
  // ...
}

type User = Awaited<ReturnType<typeof fetchUser>>;
// { id: number; name: string; }

5. Template Literal Types - 문자열 타입 조합

Template Literal Types는 문자열 리터럴 타입을 조합하여 새로운 타입을 만듭니다.

기본 사용법

type Greeting = `Hello, ${string}!`;

const g1: Greeting = "Hello, World!";     // ✅
const g2: Greeting = "Hello, Alice!";     // ✅
const g3: Greeting = "Hi, World!";        // ❌ 오류: "Hi"로 시작

실전 예제 1: HTTP 메서드와 경로 조합

type Method = "GET" | "POST" | "PUT" | "DELETE";
type Path = "/users" | "/products" | "/orders";

type Endpoint = `${Method} ${Path}`;
// "GET /users" | "GET /products" | "GET /orders" |
// "POST /users" | "POST /products" | ...

function handleRequest(endpoint: Endpoint) {
  console.log(endpoint);
}

handleRequest("GET /users");      // ✅
handleRequest("POST /products");  // ✅
handleRequest("GET /invalid");    // ❌ 오류

실전 예제 2: 이벤트 핸들러 타입

type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

interface ButtonProps {
  onClick?: () => void;
  onFocus?: () => void;
  onBlur?: () => void;
}

실전 예제 3: CSS 속성 생성

type CSSProperty = "margin" | "padding";
type Direction = "top" | "right" | "bottom" | "left";

type LonghandProperty = `${CSSProperty}${Capitalize<Direction>}`;
// "marginTop" | "marginRight" | "marginBottom" | "marginLeft" |
// "paddingTop" | "paddingRight" | ...

interface CSSStyles {
  marginTop?: string;
  marginRight?: string;
  paddingTop?: string;
  // ...
}

6. Utility Types - 내장 유틸리티 타입

TypeScript는 자주 사용하는 타입 변환을 위한 유틸리티 타입을 내장하고 있습니다.

전체 목록

// 1. Partial<T> - 모든 속성을 선택적으로
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// 2. Required<T> - 모든 속성을 필수로
type Required<T> = {
  [P in keyof T]-?: T[P];
};

// 3. Readonly<T> - 모든 속성을 읽기 전용으로
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

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

// 5. Omit<T, K> - 특정 속성 제외
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// 6. Record<K, T> - 키-값 객체 생성
type Record<K extends string | number | symbol, T> = {
  [P in K]: T;
};

// 7. Exclude<T, U> - T에서 U 제외
type Exclude<T, U> = T extends U ? never : T;

// 8. Extract<T, U> - T와 U의 교집합
type Extract<T, U> = T extends U ? T : never;

// 9. NonNullable<T> - null과 undefined 제거
type NonNullable<T> = T extends null | undefined ? never : T;

// 10. ReturnType<T> - 함수 반환 타입 추출
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

// 11. Parameters<T> - 함수 매개변수 타입 추출
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

// 12. Awaited<T> - Promise 언래핑
type Awaited<T> = T extends Promise<infer U> ? U : T;

활용 조합

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

// 1. 생성 시 사용 (id, createdAt 제외)
type CreateUserInput = Omit<User, "id" | "createdAt">;

// 2. 업데이트 시 사용 (일부 필드만)
type UpdateUserInput = Partial<Omit<User, "id" | "createdAt">>;

// 3. 공개 프로필 (민감 정보 제외)
type PublicProfile = Omit<User, "password" | "email">;

// 4. 읽기 전용 사용자
type ReadonlyUser = Readonly<User>;

7. Type Guards - 타입 좁히기

Type Guards는 런타임에 타입을 확인하여 TypeScript가 타입을 좁힐 수 있게 합니다.

typeof 가드

function processValue(value: string | number) {
  if (typeof value === "string") {
    // 여기서 value는 string
    console.log(value.toUpperCase());
  } else {
    // 여기서 value는 number
    console.log(value.toFixed(2));
  }
}

instanceof 가드

class Dog {
  bark() {
    console.log("Woof!");
  }
}

class Cat {
  meow() {
    console.log("Meow!");
  }
}

function makeSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark();  // Dog 타입
  } else {
    animal.meow();  // Cat 타입
  }
}

in 연산자 가드

interface Bird {
  fly: () => void;
}

interface Fish {
  swim: () => void;
}

function move(animal: Bird | Fish) {
  if ("fly" in animal) {
    animal.fly();   // Bird 타입
  } else {
    animal.swim();  // Fish 타입
  }
}

사용자 정의 Type Guard

interface User {
  id: number;
  name: string;
  email: string;
}

interface Admin {
  id: number;
  name: string;
  email: string;
  permissions: string[];
}

// Type Guard 함수
function isAdmin(user: User | Admin): user is Admin {
  return "permissions" in user;
}

function greet(user: User | Admin) {
  if (isAdmin(user)) {
    // 여기서 user는 Admin
    console.log(`Admin ${user.name} with ${user.permissions.length} permissions`);
  } else {
    // 여기서 user는 User
    console.log(`User ${user.name}`);
  }
}

8. 실전 패턴: 타입 안전한 Redux Action

Redux에서 액션을 타입 안전하게 만드는 패턴입니다.

❌ 타입 안전하지 않은 코드

const INCREMENT = "INCREMENT";
const DECREMENT = "DECREMENT";

function reducer(state: number, action: any) {
  switch (action.type) {
    case INCREMENT:
      return state + action.payload;  // payload가 있다는 보장 없음
    case DECREMENT:
      return state - action.payload;
    default:
      return state;
  }
}

// 오타가 있어도 오류 없음
dispatch({ type: "INCREAMENT", payload: 1 });

✅ 타입 안전한 코드

// 1. 액션 타입 정의
type IncrementAction = {
  type: "INCREMENT";
  payload: number;
};

type DecrementAction = {
  type: "DECREMENT";
  payload: number;
};

type ResetAction = {
  type: "RESET";
};

// 2. 모든 액션의 유니온
type Action = IncrementAction | DecrementAction | ResetAction;

// 3. 액션 생성자
function increment(amount: number): IncrementAction {
  return { type: "INCREMENT", payload: amount };
}

function decrement(amount: number): DecrementAction {
  return { type: "DECREMENT", payload: amount };
}

function reset(): ResetAction {
  return { type: "RESET" };
}

// 4. 리듀서
function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "INCREMENT":
      return state + action.payload;  // ✅ payload 보장
    case "DECREMENT":
      return state - action.payload;  // ✅ payload 보장
    case "RESET":
      return 0;
    default:
      const exhaustiveCheck: never = action;
      throw new Error(`Unhandled action: ${exhaustiveCheck}`);
  }
}

// 사용
dispatch(increment(1));         // ✅
dispatch(decrement(1));         // ✅
dispatch({ type: "RESET" });    // ✅
dispatch({ type: "INCREAMENT" }); // ❌ 오류: 오타

9. 실전 패턴: 타입 안전한 API 클라이언트

목표

const api = createApiClient({
  baseUrl: "https://api.example.com"
});

// 타입 안전하게 API 호출
const user = await api.get("/users/:id", { params: { id: 1 } });
// user는 자동으로 올바른 타입

구현

type PathParams<Path extends string> =
  Path extends `:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof PathParams<Rest>]: string | number }
    : Path extends `:${infer Param}`
    ? { [K in Param]: string | number }
    : {};

interface ApiEndpoints {
  "GET /users/:id": { id: number; name: string; email: string };
  "GET /posts/:id": { id: number; title: string; content: string };
  "POST /users": { name: string; email: string };
}

type ExtractMethod<T extends string> = T extends `${infer Method} ${string}` ? Method : never;
type ExtractPath<T extends string> = T extends `${string} ${infer Path}` ? Path : never;

function createApiClient() {
  return {
    async get<E extends Extract<keyof ApiEndpoints, `GET ${string}`>>(
      endpoint: ExtractPath<E>,
      options: { params: PathParams<ExtractPath<E>> }
    ): Promise<ApiEndpoints[E]> {
      // 실제 API 호출 로직
      return {} as ApiEndpoints[E];
    }
  };
}

// 사용
const api = createApiClient();
const user = await api.get("/users/:id", { params: { id: 1 } });
// user: { id: number; name: string; email: string }

함정과 주의사항

❌ 실수 1: 제네릭 남용

// ❌ 불필요한 제네릭
function add<T extends number>(a: T, b: T): number {
  return a + b;
}

// ✅ 단순하게
function add(a: number, b: number): number {
  return a + b;
}

언제 제네릭을 사용할까?

  • 입력 타입과 출력 타입이 연관될 때
  • 여러 타입에 대해 동일한 로직을 재사용할 때

❌ 실수 2: any로 도피

// ❌ any로 문제 회피
function processData(data: any): any {
  return data;
}

// ✅ 제네릭 사용
function processData<T>(data: T): T {
  return data;
}

❌ 실수 3: 과도하게 복잡한 타입

// ❌ 가독성이 떨어지는 타입
type ComplexType<T> = T extends Array<infer U>
  ? U extends { [K in keyof U]: infer V }
    ? V extends string
      ? Uppercase<V>
      : never
    : never
  : never;

// ✅ 단계별로 나누기
type ExtractArrayItem<T> = T extends Array<infer U> ? U : never;
type ExtractStringValues<T> = T[keyof T] extends string ? T[keyof T] : never;
type UppercaseValues<T> = Uppercase<ExtractStringValues<T>>;

⚠️ 주의: 타입 추론의 한계

TypeScript는 모든 것을 추론할 수 없습니다.

// 추론이 부정확할 수 있음
const config = {
  production: { apiUrl: "https://api.example.com" },
  development: { apiUrl: "http://localhost:3000" }
};

// ✅ 명시적 타입으로 개선
interface Config {
  apiUrl: string;
}

const config: Record<"production" | "development", Config> = {
  production: { apiUrl: "https://api.example.com" },
  development: { apiUrl: "http://localhost:3000" }
};

활용 팁

1. 타입을 먼저 설계하라

코드를 작성하기 전에 타입부터 설계하면, 더 안전한 코드를 작성할 수 있습니다.

// 1. 먼저 타입 설계
interface User {
  id: number;
  name: string;
  email: string;
}

type CreateUserInput = Omit<User, "id">;
type UpdateUserInput = Partial<CreateUserInput>;

// 2. 함수 구현
function createUser(input: CreateUserInput): User {
  // ...
}

function updateUser(id: number, input: UpdateUserInput): User {
  // ...
}

2. 유틸리티 타입을 적극 활용하라

TypeScript의 내장 유틸리티 타입을 사용하면 코드 중복을 줄일 수 있습니다.

// ❌ 중복 코드
interface User {
  id: number;
  name: string;
  email: string;
}

interface PartialUser {
  id?: number;
  name?: string;
  email?: string;
}

// ✅ 유틸리티 타입 활용
type PartialUser = Partial<User>;

3. Type Guard를 활용하라

런타임 타입 체크와 TypeScript 타입 좁히기를 결합하면 더 안전합니다.

function isString(value: unknown): value is string {
  return typeof value === "string";
}

function processValue(value: unknown) {
  if (isString(value)) {
    // value는 string으로 좁혀짐
    console.log(value.toUpperCase());
  }
}

마치며

TypeScript의 고급 타입은 처음에는 복잡해 보이지만, 타입 시스템의 진짜 힘을 발휘하게 해줍니다.

  • Generics로 재사용 가능한 컴포넌트 작성
  • Mapped Types로 타입 변환 자동화
  • Conditional Types로 조건부 로직을 타입에 적용
  • Template Literal Types로 문자열 타입 조합
  • Type Guards로 런타임 안전성 확보

저도 처음에는 “이렇게까지 해야 하나?”라고 생각했지만, 고급 타입을 사용하면서:

  • 리팩토링이 안전해졌고
  • 버그가 눈에 띄게 줄었으며
  • 코드 자동완성이 훨씬 정확해졌습니다

여러분도 고급 타입에 익숙해지면, TypeScript의 진짜 가치를 느끼실 거예요!


참고 자료

다음에 읽을 글

댓글