TypeScript
TypeScript 타입을 마치 주석처럼 읽힐 수 있게 쓰기
TypeScript 타입 정의를 명확하고 이해하기 쉽게 작성하는 방법을 정리했다.
TypeScript를 쓰는 이유는 타입이 코드의 의도를 명확히 하기 때문이다. 하지만 복잡하게 쓰인 타입은 오히려 코드를 읽기 어렵게 만든다.
타입을 "실행 가능한 주석"으로 생각하고, 코드를 읽는 사람이 쉽게 이해할 수 있도록 쓰는 게 중요하다.
나쁜 예: 복잡한 유니언 타입
// 읽기 어렵다
type Handler = (req: any, res: any) => void | Promise<void> | { json: (data: any) => void };
좋은 예: 명확한 이름과 구조
type ApiResponse = {
json: (data: any) => void;
};
type RequestHandler = (req: any, res: ApiResponse) => void | Promise<void>;
이렇게 하면 RequestHandler가 무엇인지 한눈에 이해된다.
불필요한 any 피하기
// 나쁜 예
function process(data: any): any {
return data;
}
// 좋은 예
function process<T>(data: T): T {
return data;
}
제네릭을 사용하면 실제 타입 정보를 유지하면서도 유연성이 생긴다.
복잡한 인터페이스를 작은 부분으로 나누기
// 나쁜 예: 한 번에 모든 필드
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
permissions: string[];
createdAt: Date;
updatedAt: Date;
isActive: boolean;
profile: { bio: string; avatar: string };
settings: { notifications: boolean; theme: 'light' | 'dark' };
}
// 좋은 예: 논리적으로 분리
interface UserProfile {
bio: string;
avatar: string;
}
interface UserSettings {
notifications: boolean;
theme: 'light' | 'dark';
}
interface User {
id: string;
name: string;
email: string;
role: UserRole;
permissions: Permission[];
createdAt: Date;
updatedAt: Date;
isActive: boolean;
profile: UserProfile;
settings: UserSettings;
}
의미 있는 타입 별칭 사용
// 나쁜 예
type T = string | null;
function getName(): T { ... }
// 좋은 예
type OptionalString = string | null;
function getName(): OptionalString { ... }
// 더 좋은 예: 도메인 이름 사용
type UserName = string | null;
function getUserName(): UserName { ... }
유니언 타입 정리하기
// 읽기 어려운 유니언
type Status = 'pending' | 'success' | 'error' | 'loading' | 'idle';
// 카테고리로 나누기
type LoadingStatus = 'loading' | 'idle';
type TerminalStatus = 'success' | 'error';
type PendingStatus = 'pending';
type ApiStatus = LoadingStatus | TerminalStatus | PendingStatus;
함수 시그니처 명확하게
// 나쁜 예
const handler = (x: any, y?: boolean) => { ... };
// 좋은 예
interface FetchOptions {
includeMetadata?: boolean;
}
const handler = (url: string, options?: FetchOptions) => { ... };
매개변수의 이름과 타입으로 용도가 명확해진다.
제네릭에서도 타입 제약 명확하게
// 나쁜 예
function transform<T>(value: T): T { ... }
// 좋은 예: 제약 조건 명시
function transform<T extends string | number>(value: T): T { ... }
// 더 좋은 예: 서술적 이름
function transform<NumericValue extends string | number>(value: NumericValue): NumericValue { ... }
타입 가드 활용
// 나쁜 예: any 캐스팅
function process(data: any) {
const users = data as User[];
users.forEach(u => console.log(u.name));
}
// 좋은 예: 타입 가드
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'email' in obj
);
}
function process(data: unknown) {
if (Array.isArray(data) && data.every(isUser)) {
data.forEach(u => console.log(u.name));
}
}
코드 예시: 나쁜 타입 정의의 개선
// Before: 복잡하고 읽기 어렵다
type ApiHandlerParams = {
req: { url: string; method: 'GET' | 'POST' | 'PUT' | 'DELETE'; headers: Record<string, string>; body?: any };
res: { status: (code: number) => { json: (data: any) => void; send: (data: string) => void } };
};
const handler = (params: ApiHandlerParams) => { ... };
// After: 명확하고 읽기 쉽다
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
interface ApiRequest {
url: string;
method: HttpMethod;
headers: Record<string, string>;
body?: unknown;
}
interface ApiResponse {
status: (code: number) => ApiResponseBuilder;
}
interface ApiResponseBuilder {
json: (data: unknown) => void;
send: (data: string) => void;
}
interface ApiHandlerContext {
req: ApiRequest;
res: ApiResponse;
}
const handler = (context: ApiHandlerContext) => { ... };
타입 검토 체크리스트
- 이 타입을 처음 본 사람도 이해할 수 있는가?
- 타입 이름이 실제 의도를 반영하는가?
- 불필요한
any가 있는가? - 복잡한 유니언이나 인터페이스를 더 작게 나눌 수 있는가?
- 제네릭이 사용되었다면, 제약 조건이 명확한가?
결론
TypeScript의 타입 시스템은 코드의 안전성을 높이는 도구지만, 복잡하게 쓰면 오히려 코드를 읽기 어렵게 만든다. 타입을 "설명하는 주석"으로 생각하고, 다른 개발자가 타입만 봐도 코드의 의도를 이해할 수 있도록 쓰자.