TypeScript 실전 가이드: 타입 시스템 제대로 활용하기
TypeScript의 타입 시스템을 실무에서 효과적으로 활용하는 방법. 제네릭, 유틸리티 타입, 타입 가드 등 고급 기능을 다룹니다.
TypeScript는 JavaScript에 정적 타입 시스템을 추가한 언어입니다. 2026년 현재, 대부분의 새로운 프로젝트는 TypeScript로 시작되며, 기존 JavaScript 프로젝트들도 TypeScript로 마이그레이션하고 있습니다. 이 글에서는 TypeScript의 타입 시스템을 실무에서 효과적으로 활용하는 방법을 알아보겠습니다.
TypeScript를 사용해야 하는 이유
JavaScript는 동적 타입 언어입니다. 변수의 타입이 런타임에 결정되므로, 타입 관련 버그가 프로덕션에서 발견되는 경우가 많습니다. TypeScript는 컴파일 타임에 타입을 검사하여 이러한 버그를 미리 잡아냅니다.
TypeScript의 주요 장점은 다음과 같습니다.
첫째, 버그를 조기에 발견할 수 있습니다. 코드를 작성하는 시점에 타입 오류를 발견할 수 있어, 디버깅 시간이 크게 줄어듭니다.
둘째, IDE 지원이 뛰어납니다. 자동 완성, 리팩토링, 정의로 이동 등의 기능이 정확하게 작동합니다. 이는 개발 생산성을 크게 향상시킵니다.
셋째, 코드가 문서화됩니다. 타입 정의 자체가 코드의 사용 방법을 설명하는 문서 역할을 합니다.
넷째, 리팩토링이 안전해집니다. 타입 시스템이 변경의 영향을 파악해주므로, 대규모 리팩토링도 자신 있게 할 수 있습니다.
기본 타입 시스템
TypeScript의 기본 타입들을 살펴보겠습니다.
// 기본 타입
const name: string = "홍길동";
const age: number = 25;
const isActive: boolean = true;
const nothing: null = null;
const notDefined: undefined = undefined;
// 배열
const numbers: number[] = [1, 2, 3];
const strings: Array<string> = ["a", "b", "c"];
// 튜플 - 고정된 길이와 타입
const tuple: [string, number] = ["hello", 42];
// 객체 타입
const user: { name: string; age: number } = {
name: "홍길동",
age: 25,
};
// 유니온 타입 - 여러 타입 중 하나
let value: string | number = "hello";
value = 42; // OK
// 리터럴 타입 - 특정 값만 허용
type Direction = "north" | "south" | "east" | "west";
const dir: Direction = "north";
인터페이스와 타입 별칭
객체의 구조를 정의하는 두 가지 방법이 있습니다.
// 인터페이스
interface User {
id: number;
name: string;
email: string;
age?: number; // 선택적 속성
readonly createdAt: Date; // 읽기 전용
}
// 타입 별칭
type Product = {
id: number;
name: string;
price: number;
};
// 확장
interface Admin extends User {
role: "admin";
permissions: string[];
}
type VIPUser = User & {
vipLevel: number;
benefits: string[];
};
인터페이스와 타입 별칭은 대부분의 경우 호환됩니다. 하지만 인터페이스는 선언 병합이 가능하고, 타입 별칭은 유니온 타입이나 튜플을 정의할 때 더 적합합니다.
제네릭: 재사용 가능한 타입
제네릭은 타입을 매개변수화하여 재사용 가능한 컴포넌트를 만들 수 있게 해줍니다.
// 제네릭 함수
function identity<T>(arg: T): T {
return arg;
}
const result1 = identity<string>("hello"); // string
const result2 = identity(42); // number (타입 추론)
// 제네릭 인터페이스
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
type UserResponse = ApiResponse<User>;
type ProductListResponse = ApiResponse<Product[]>;
// 제네릭 제약 조건
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
logLength("hello"); // OK - string은 length 속성이 있음
logLength([1, 2, 3]); // OK - 배열은 length 속성이 있음
// logLength(123); // Error - number는 length 속성이 없음
React에서의 제네릭 활용
// 제네릭 컴포넌트
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// 사용 예시
<List
items={users}
renderItem={user => <span>{user.name}</span>}
keyExtractor={user => user.id}
/>
유틸리티 타입
TypeScript는 타입 변환을 위한 다양한 유틸리티 타입을 제공합니다.
interface User {
id: number;
name: string;
email: string;
password: string;
}
// Partial<T> - 모든 속성을 선택적으로
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; password?: string; }
// Required<T> - 모든 속성을 필수로
type RequiredUser = Required<PartialUser>;
// Pick<T, K> - 특정 속성만 선택
type UserCredentials = Pick<User, "email" | "password">;
// { email: string; password: string; }
// Omit<T, K> - 특정 속성 제외
type PublicUser = Omit<User, "password">;
// { id: number; name: string; email: string; }
// Record<K, T> - 키-값 쌍 타입 생성
type UserRoles = Record<string, "admin" | "user" | "guest">;
// { [key: string]: "admin" | "user" | "guest" }
// Readonly<T> - 모든 속성을 읽기 전용으로
type ImmutableUser = Readonly<User>;
// ReturnType<T> - 함수의 반환 타입 추출
function createUser() {
return { id: 1, name: "John" };
}
type NewUser = ReturnType<typeof createUser>;
// { id: number; name: string; }
실전 활용 예시
// API 응답 타입 정의
interface ApiResponse<T> {
data: T;
meta: {
total: number;
page: number;
pageSize: number;
};
}
// 사용자 업데이트 함수
async function updateUser(
id: number,
updates: Partial<Omit<User, "id">>
): Promise<User> {
const response = await fetch(`/api/users/${id}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
return response.json();
}
// id를 제외한 모든 필드가 선택적으로 업데이트 가능
await updateUser(1, { name: "새 이름" });
await updateUser(1, { email: "new@email.com", name: "새 이름" });
타입 가드
타입 가드는 런타임에 타입을 좁히는 기법입니다.
// typeof 가드
function processValue(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // value는 string
}
return value.toFixed(2); // value는 number
}
// instanceof 가드
class Dog {
bark() { console.log("Woof!"); }
}
class Cat {
meow() { console.log("Meow!"); }
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark();
} else {
animal.meow();
}
}
// in 연산자 가드
interface Fish { swim: () => void; }
interface Bird { fly: () => void; }
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim();
} else {
animal.fly();
}
}
// 사용자 정의 타입 가드
function isFish(animal: Fish | Bird): animal is Fish {
return (animal as Fish).swim !== undefined;
}
Discriminated Unions (판별 유니온)
공통 속성으로 타입을 구분하는 패턴입니다.
interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
type Shape = Circle | Rectangle | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}
타입 추론 활용하기
TypeScript는 강력한 타입 추론 기능을 제공합니다. 명시적으로 타입을 선언하지 않아도 되는 경우가 많습니다.
// 변수 초기화 시 타입 추론
const name = "홍길동"; // string
const age = 25; // number
const items = [1, 2, 3]; // number[]
// 함수 반환 타입 추론
function add(a: number, b: number) {
return a + b; // 반환 타입 number로 추론
}
// 컨텍스트 타입 추론
const numbers = [1, 2, 3];
const doubled = numbers.map(n => n * 2); // n은 number로 추론
하지만 함수의 매개변수, 복잡한 객체, 공개 API 등은 명시적으로 타입을 선언하는 것이 좋습니다.
실전 팁
as const 활용
// 리터럴 타입으로 좁히기
const config = {
endpoint: "https://api.example.com",
timeout: 5000,
} as const;
// config.endpoint는 "https://api.example.com" 타입 (string이 아님)
// config.timeout은 5000 타입 (number가 아님)
// 객체도 readonly가 됨
satisfies 연산자 (TypeScript 4.9+)
type Colors = Record<string, [number, number, number] | string>;
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies Colors;
// palette.red는 [number, number, number]로 정확히 추론됨
const r = palette.red[0]; // OK, r은 number
마무리
TypeScript의 타입 시스템은 매우 강력합니다. 제네릭, 유틸리티 타입, 타입 가드 등을 잘 활용하면 안전하면서도 유연한 코드를 작성할 수 있습니다.
처음에는 타입을 정의하는 데 시간이 더 걸릴 수 있지만, 프로젝트가 커질수록 TypeScript의 가치는 더욱 빛을 발합니다. 버그를 미리 잡아주고, 리팩토링을 안전하게 해주며, 코드의 의도를 명확하게 전달해줍니다.
다음 프로젝트에서 이 글의 내용을 적용해보시기 바랍니다.