TypeScript 완벽 가이드 - 타입 시스템부터 고급 패턴까지

TypeScript는 JavaScript에 정적 타입을 추가한 언어입니다. 컴파일 시점에 오류를 발견하고, 더 나은 개발 경험을 제공합니다. 이 글에서는 TypeScript의 타입 시스템을 체계적으로 다루겠습니다.

TypeScript 타입 시스템

TypeScript는 다양한 타입을 제공하여 코드의 안정성을 높입니다.

TypeScript 타입 시스템
TypeScript의 기본 타입, 객체 타입, 특수 타입

기본 타입

let name: string = "TypeScript";
let count: number = 42;
let isActive: boolean = true;

let nothing: null = null;
let notDefined: undefined = undefined;

let uniqueKey: symbol = Symbol("key");

let bigNumber: bigint = 9007199254740991n;

배열과 튜플

let numbers: number[] = [1, 2, 3, 4, 5];
let strings: Array<string> = ["a", "b", "c"];

let point: [number, number] = [10, 20];
let record: [string, number, boolean] = ["age", 25, true];

const rgb: readonly [number, number, number] = [255, 128, 0];

객체 타입

let user: { name: string; age: number } = {
  name: "Kim",
  age: 30
};

interface User {
  id: number;
  name: string;
  email: string;
  age?: number;
  readonly createdAt: Date;
}

type Product = {
  id: string;
  name: string;
  price: number;
};

interface Admin extends User {
  role: string;
  permissions: string[];
}

유니온과 인터섹션 타입

유니온 타입

type StringOrNumber = string | number;

function printId(id: string | number) {
  if (typeof id === "string") {
    console.log(id.toUpperCase());
  } else {
    console.log(id.toFixed(2));
  }
}

type Status = "pending" | "approved" | "rejected";

function setStatus(status: Status) {
  console.log(`Status: ${status}`);
}

인터섹션 타입

type HasName = { name: string };
type HasAge = { age: number };

type Person = HasName & HasAge;

const person: Person = {
  name: "Kim",
  age: 30
};

type WithTimestamp<T> = T & {
  createdAt: Date;
  updatedAt: Date;
};

type UserWithTimestamp = WithTimestamp<User>;
유니온은 “A 또는 B” (A | B), 인터섹션은 “A와 B 모두” (A & B)입니다. 상황에 맞게 선택하세요.

타입 가드와 타입 좁히기

function processValue(value: string | number | null) {
  if (value === null) {
    return "No value";
  }
  
  if (typeof value === "string") {
    return value.toUpperCase();
  }
  
  return value * 2;
}

interface Dog {
  type: "dog";
  bark(): void;
}

interface Cat {
  type: "cat";
  meow(): void;
}

type Animal = Dog | Cat;

function makeSound(animal: Animal) {
  switch (animal.type) {
    case "dog":
      animal.bark();
      break;
    case "cat":
      animal.meow();
      break;
  }
}

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

function process(value: unknown) {
  if (isString(value)) {
    console.log(value.toUpperCase());
  }
}

제네릭

제네릭은 타입을 매개변수로 받아 재사용 가능한 컴포넌트를 만듭니다.

TypeScript 제네릭
제네릭을 사용한 타입 안전한 함수와 클래스

기본 제네릭

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

const str = identity<string>("hello");
const num = identity(42);

function map<T, U>(array: T[], fn: (item: T) => U): U[] {
  return array.map(fn);
}

const lengths = map(["a", "bb", "ccc"], s => s.length);

제네릭 인터페이스와 클래스

interface Repository<T> {
  findById(id: string): T | undefined;
  findAll(): T[];
  save(entity: T): void;
  delete(id: string): void;
}

class GenericRepository<T extends { id: string }> implements Repository<T> {
  private items: Map<string, T> = new Map();

  findById(id: string): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  save(entity: T): void {
    this.items.set(entity.id, entity);
  }

  delete(id: string): void {
    this.items.delete(id);
  }
}

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

const userRepo = new GenericRepository<User>();

제네릭 제약 조건

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length);
  return arg;
}

logLength("hello");
logLength([1, 2, 3]);
logLength({ length: 10 });

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

const user = { name: "Kim", age: 30 };
const name = getProperty(user, "name");
const age = getProperty(user, "age");

유틸리티 타입

TypeScript는 타입 변환을 위한 내장 유틸리티 타입을 제공합니다.

TypeScript 유틸리티 타입
주요 유틸리티 타입과 사용 용도

Partial과 Required

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

type PartialUser = Partial<User>;

function updateUser(id: string, updates: Partial<User>) {
  console.log(updates);
}

updateUser("1", { name: "New Name" });

type RequiredUser = Required<User>;

Pick과 Omit

type UserPreview = Pick<User, "id" | "name">;

type UserWithoutId = Omit<User, "id">;

type CreateUserDTO = Omit<User, "id">;
type UpdateUserDTO = Partial<Omit<User, "id">>;

Record

type PageInfo = {
  title: string;
  url: string;
};

type Pages = Record<string, PageInfo>;

const pages: Pages = {
  home: { title: "Home", url: "/" },
  about: { title: "About", url: "/about" }
};

type Role = "admin" | "user" | "guest";
type Permissions = Record<Role, string[]>;

const rolePermissions: Permissions = {
  admin: ["read", "write", "delete"],
  user: ["read", "write"],
  guest: ["read"]
};

ReturnType과 Parameters

function createUser(name: string, age: number) {
  return { id: Date.now().toString(), name, age };
}

type User2 = ReturnType<typeof createUser>;

type CreateUserParams = Parameters<typeof createUser>;

async function fetchData(): Promise<{ data: string }> {
  return { data: "result" };
}

type FetchResult = Awaited<ReturnType<typeof fetchData>>;

조건부 타입

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;
type B = IsString<number>;

type ExtractString<T> = T extends string ? T : never;
type OnlyStrings = ExtractString<string | number | boolean>;

type Flatten<T> = T extends Array<infer U> ? U : T;
type Num = Flatten<number[]>;
type Str = Flatten<string>;

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Data = UnwrapPromise<Promise<{ name: string }>>;

매핑된 타입

type Readonly2<T> = {
  readonly [P in keyof T]: T[P];
};

type Optional<T> = {
  [P in keyof T]?: T[P];
};

type Mutable<T> = {
  -readonly [P in keyof T]: T[P];
};

type Nullable<T> = {
  [P in keyof T]: T[P] | null;
};

type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};

interface Person {
  name: string;
  age: number;
}

type PersonGetters = Getters<Person>;

템플릿 리터럴 타입

type EventName = `on${Capitalize<string>}`;

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiEndpoint = `/api/${"users" | "posts" | "comments"}`;

type Route = `${HttpMethod} ${ApiEndpoint}`;

type PropEventName<T extends string> = `${T}Changed`;

type UserPropEvents = PropEventName<"name" | "age" | "email">;

type CSSValue = `${number}${"px" | "em" | "rem" | "%"}`;

const width: CSSValue = "100px";
const height: CSSValue = "50%";

클래스와 타입

abstract class Entity {
  abstract id: string;
  abstract validate(): boolean;
}

class User3 extends Entity {
  id: string;
  name: string;
  private password: string;
  protected role: string;
  readonly createdAt: Date;

  constructor(id: string, name: string, password: string) {
    super();
    this.id = id;
    this.name = name;
    this.password = password;
    this.role = "user";
    this.createdAt = new Date();
  }

  validate(): boolean {
    return this.name.length > 0 && this.password.length >= 8;
  }

  static create(name: string, password: string): User3 {
    return new User3(crypto.randomUUID(), name, password);
  }
}

interface Serializable {
  serialize(): string;
}

interface Comparable<T> {
  compareTo(other: T): number;
}

class Product2 implements Serializable, Comparable<Product2> {
  constructor(
    public id: string,
    public name: string,
    public price: number
  ) {}

  serialize(): string {
    return JSON.stringify(this);
  }

  compareTo(other: Product2): number {
    return this.price - other.price;
  }
}

타입 선언 파일

전역 타입 선언

declare global {
  interface Window {
    myApp: {
      version: string;
      init(): void;
    };
  }
}

declare module "*.svg" {
  const content: string;
  export default content;
}

declare module "*.json" {
  const value: unknown;
  export default value;
}

모듈 확장

import "express";

declare module "express" {
  interface Request {
    user?: {
      id: string;
      role: string;
    };
  }
}
외부 라이브러리에 타입이 없다면 @types/ 패키지를 찾거나, 직접 d.ts 파일을 작성하세요. DefinitelyTyped 저장소에서 많은 타입 정의를 찾을 수 있습니다.

실전 패턴

빌더 패턴

class QueryBuilder<T> {
  private query: Partial<T> = {};

  where<K extends keyof T>(key: K, value: T[K]): this {
    this.query[key] = value;
    return this;
  }

  build(): Partial<T> {
    return { ...this.query };
  }
}

interface UserQuery {
  name: string;
  age: number;
  role: string;
}

const query = new QueryBuilder<UserQuery>()
  .where("name", "Kim")
  .where("age", 30)
  .build();

타입 안전한 이벤트 시스템

type EventMap = {
  userLogin: { userId: string; timestamp: Date };
  userLogout: { userId: string };
  dataLoaded: { data: unknown[] };
};

class TypedEventEmitter<T extends Record<string, unknown>> {
  private listeners: Map<keyof T, Set<(data: unknown) => void>> = new Map();

  on<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (data: unknown) => void);
  }

  emit<K extends keyof T>(event: K, data: T[K]): void {
    this.listeners.get(event)?.forEach(listener => listener(data));
  }
}

const emitter = new TypedEventEmitter<EventMap>();

emitter.on("userLogin", ({ userId, timestamp }) => {
  console.log(`User ${userId} logged in at ${timestamp}`);
});

emitter.emit("userLogin", { userId: "123", timestamp: new Date() });

마무리

TypeScript의 타입 시스템은 강력하고 표현력이 뛰어납니다. 핵심 포인트를 정리하면 다음과 같습니다.

  1. 정적 타입으로 컴파일 시점에 오류 발견
  2. 제네릭으로 재사용 가능한 타입 안전 코드 작성
  3. 유틸리티 타입으로 타입 변환 간소화
  4. 조건부 타입매핑된 타입으로 고급 타입 조작
  5. 타입 가드로 런타임 타입 안전성 확보

다음 글에서는 TypeScript 프로젝트 설정과 고급 컴파일러 옵션에 대해 다루겠습니다.