TypeScript 모범 사례: 더 안전하고 유지보수하기 쉬운 코드 작성하기
가장 중요한 TypeScript 패턴을 익혀보세요 — strict 모드, 유틸리티 타입, 제네릭, 판별 유니온, 그리고 타입 시스템 우회 방법 피하기.
TypeScript는 대부분의 전문적인 JavaScript 프로젝트에서 선택 사항에서 필수 요소로 자리잡았습니다. 하지만 "기술적으로 TypeScript를 사용하는 것"과 "실제로 버그를 잡아내는 TypeScript"는 큰 차이가 있습니다. 그 차이는 타입 시스템의 진정한 힘을 끌어내는 몇 가지 핵심 패턴에 달려 있습니다.
strict 모드 활성화 — 항상
TypeScript 설정에서 가장 효과적인 단 하나의 변경:
// tsconfig.json
{
"compilerOptions": {
"strict": true
}
}
strict는 다음 검사들을 한 번에 활성화합니다:
strictNullChecks—null과undefined를 다른 타입에 할당할 수 없음noImplicitAny— 변수에 암묵적으로any타입을 부여할 수 없음strictFunctionTypes— 함수 매개변수 검사 강화strictPropertyInitialization— 클래스 속성은 반드시 초기화되어야 함
strictNullChecks 없이는 TypeScript가 가장 흔한 런타임 오류를 잡아내지 못합니다. 처음부터 활성화하세요 — 대규모 코드베이스에 나중에 적용하는 것은 매우 고통스럽습니다.
any 사용 금지 — 대신 unknown 사용
any는 모든 오류를 묵살하고 TypeScript의 목적을 무력화하는 타입 시스템 우회 수단입니다:
// ❌ any는 모든 타입 검사를 비활성화
function processData(data: any) {
data.nonExistentMethod(); // 오류 없음! 런타임에 충돌 발생.
}
// ✅ unknown은 사용 전에 타입을 좁히도록 강제
function processData(data: unknown) {
if (typeof data === "string") {
console.log(data.toUpperCase()); // 안전 — string으로 좁혀짐
}
if (data instanceof Error) {
console.log(data.message); // 안전 — Error로 좁혀짐
}
}
unknown은 any의 타입 안전 버전입니다. 사용하기 전에 실제 값이 무엇인지 확인하도록 강제합니다. API 응답, JSON 파싱, 외부 소스에서 오는 모든 데이터에 사용하세요.
완전한 처리를 위한 판별 유니온
TypeScript에서 가장 강력한 패턴 중 하나입니다:
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: User[] };
type ErrorState = { status: "error"; message: string };
type ApiState = LoadingState | SuccessState | ErrorState;
function render(state: ApiState) {
switch (state.status) {
case "loading":
return <Spinner />;
case "success":
return <UserList users={state.data} />; // 여기서 state.data 사용 가능
case "error":
return <ErrorMessage msg={state.message} />; // 여기서 state.message 사용 가능
default:
// ApiState에 새 상태를 추가하고 처리하지 않으면 TypeScript가 여기서 오류 발생
const _exhaustive: never = state;
throw new Error(`Unhandled state: ${_exhaustive}`);
}
}
마지막의 never 할당이 완전성 검사입니다 — ApiState에 새 변형을 추가하고 switch에서 처리하지 않으면 TypeScript가 컴파일 타임 오류를 발생시킵니다. 이는 언어 전체에서 가장 가치 있는 안전망 중 하나입니다.
유틸리티 타입
TypeScript는 내장 타입 변환기를 제공합니다:
interface User {
id: number;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
createdAt: Date;
}
// 모든 속성을 선택적으로 만들기
type PartialUser = Partial<User>;
// 모든 속성을 필수로 만들기
type RequiredUser = Required<User>;
// 모든 속성을 읽기 전용으로 만들기
type ReadonlyUser = Readonly<User>;
// 특정 속성만 선택하기
type UserPreview = Pick<User, "id" | "name">;
// 특정 속성 제외하기
type UserWithoutDates = Omit<User, "createdAt">;
// 객체의 값으로 타입 만들기
type UserRole = User["role"]; // "admin" | "editor" | "viewer"
// 속성을 nullable로 만들기
type NullableUser = { [K in keyof User]: User[K] | null };
// Record 타입: 특정 키와 값 타입을 가진 객체
type RolePermissions = Record<UserRole, string[]>;
// 유니온 타입에서 추출/제외
type AdminOrEditor = Extract<UserRole, "admin" | "editor">; // "admin" | "editor"
type NonAdmin = Exclude<UserRole, "admin">; // "editor" | "viewer"
재사용 가능하고 타입 안전한 코드를 위한 제네릭
제네릭을 사용하면 완전한 타입 안전성을 유지하면서 어떤 타입에도 동작하는 함수와 클래스를 작성할 수 있습니다:
// 제네릭 없이 — 타입 정보 손실
function first(arr: any[]): any {
return arr[0];
}
// 제네릭 사용 — 타입 보존
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // 타입: number | undefined
const str = first(["a", "b"]); // 타입: string | undefined
제네릭 제약
// T는 'id' 속성을 가져야 함
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
// K는 T의 키여야 함
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Alice", role: "admin" };
const name = getProperty(user, "name"); // 타입: string
const role = getProperty(user, "role"); // 타입: string
// getProperty(user, "missing"); // ❌ TypeScript 오류 — "missing"은 키가 아님
타입 가드와 타입 좁히기
// 타입 술어 — 커스텀 타입 가드
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"email" in value
);
}
// 사용 예시
const data: unknown = await fetchUser();
if (isUser(data)) {
console.log(data.name); // TypeScript는 여기서 data가 User임을 알고 있음
}
런타임에서 API 응답을 검증하려면 TypeScript 인터페이스와 Zod를 이용한 런타임 스키마 검증을 함께 사용하세요. JSON to Zod 변환기로 JSON에서 Zod 스키마를 바로 생성할 수 있습니다 — 예시 JSON 응답을 붙여넣으면 즉시 타입 안전 스키마를 얻을 수 있습니다.
템플릿 리터럴 타입
TypeScript 4.1+는 타입 수준에서 문자열 조작을 지원합니다:
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`; // "onClick" | "onFocus" | "onBlur"
type CSSProperty = "margin" | "padding";
type CSSDirection = "top" | "right" | "bottom" | "left";
type CSSKey = `${CSSProperty}-${CSSDirection}`;
// "margin-top" | "margin-right" | ... | "padding-left"
satisfies 연산자 (TypeScript 4.9+)
값을 타입에 맞게 검증하면서 타입을 넓히지 않습니다:
type ColorMap = Record<string, string>;
// ❌ satisfies 없이 — 타입이 Record<string, string>으로 넓어짐
const colors: ColorMap = {
primary: "#3b82f6",
secondary: "#8b5cf6",
};
colors.primary.toUpperCase(); // 동작하지만 string 타입 정보 손실
// ✅ satisfies 사용 — 검증과 동시에 리터럴 타입 보존
const colors = {
primary: "#3b82f6",
secondary: "#8b5cf6",
} satisfies ColorMap;
colors.primary.toUpperCase(); // ✅ TypeScript가 string임을 알고 있음
colors.nonexistent; // ❌ 오류 — 해당 키 없음
데이터로부터 타입 생성하기
API 응답으로부터 TypeScript 인터페이스를 직접 작성하는 대신:
- 샘플 JSON 응답을 JSON to TypeScript 변환기에 붙여넣으면 즉시 인터페이스를 얻을 수 있습니다
- 또는 JSON to Zod로 Zod 스키마를 생성하세요 — Zod는
z.infer<typeof schema>를 통해 TypeScript 타입을 자동으로 추론합니다
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
});
// 타입 자동 추론 — 중복 없음
type User = z.infer<typeof UserSchema>;
// 런타임 검증과 TypeScript 타입 동시 제공
const user = UserSchema.parse(await response.json());
피해야 할 일반적인 안티 패턴
| 안티 패턴 | 문제점 | 해결책 |
|---|---|---|
as any |
타입 검사 비활성화 | unknown + 타입 좁히기 사용 |
// @ts-ignore |
수정 없이 오류 묵살 | 타입 오류 수정 |
as SomeType (안전하지 않은 캐스트) |
추론 우회 | 타입 가드 사용 |
유니온에 interface 사용 |
판별 유니온에 동작하지 않음 | type 사용 |
지나치게 일반적인 object |
구조 없음 | 적절한 인터페이스 정의 |
Function을 타입으로 사용 |
매개변수/반환 정보 없음 | 구체적인 시그니처 사용 |
알아두면 유용한 TypeScript 설정
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true, // array[n]이 T | undefined 반환
"exactOptionalPropertyTypes": true, // 누락과 undefined 구분
"noImplicitReturns": true, // 모든 코드 경로가 반환해야 함
"noFallthroughCasesInSwitch": true, // 의도하지 않은 switch 폴스루 방지
"verbatimModuleSyntax": true // 타입 전용 import에 명시적 import type 사용
}
}
TypeScript의 가치는 얼마나 엄격하게 사용하느냐에 비례합니다. 여기서 다룬 패턴들 — strict 모드, 판별 유니온, 제네릭, 완전성 검사 — 이 바로 단순히 문법을 추가하는 TypeScript와 실제로 버그를 찾아내는 TypeScript를 구분 짓는 요소입니다.