3.3 interface vs type
TypeScript를 배우다 보면 가장 먼저 혼란을 겪는 주제 중 하나가 "interface와 type 중 무엇을 써야 하나?"다. 두 도구는 많은 경우 서로 대체 가능하지만, 핵심적인 차이점이 있고 상황에 따라 하나가 더 적합하다.
이 장에서는 차이점을 기술적으로 비교하고, 실무 기준의 의사결정 가이드라인을 제시한다.
핵심 차이점 총정리
| 기능 | interface | type |
|---|---|---|
| 객체 구조 정의 | 가능 | 가능 |
| 원시 타입 별칭 | 불가 | type Name = string |
| 유니온 타입 | 불가 | type A = B | C |
| 인터섹션 타입 | extends로 유사하게 | type A = B & C |
| 리터럴 타입 | 불가 | type Dir = "left" | "right" |
| 선언 병합 | 가능 (같은 이름 중복 선언) | 불가 (오류 발생) |
| 확장 방법 | extends 키워드 | & 인터섹션 |
| 재귀 타입 | 제한적 | 자연스럽게 가능 |
| 클래스 implements | 가능 | 가능 (객체 타입인 경우) |
| 튜플/배열 타입 | 제한적 | type Pair = [string, number] |
| 조건부 타입 | 불가 | type A = T extends U ? X : Y |
| mapped 타입 | 불가 | type A = { [K in keyof T]: ... } |
| 에러 메시지 가독성 | 인터페이스 이름 표시 | 구조 전체 인라인 표시 경우 있음 |
확장 방식 차이
interface: extends 키워드
interface Animal {
name: string;
sound(): string;
}
interface Pet extends Animal {
owner: string;
vaccinated: boolean;
}
interface ServiceAnimal extends Animal {
serviceType: "guide" | "therapy" | "search-rescue";
certificationId: string;
}
// 다중 확장
interface SpecialPet extends Pet, ServiceAnimal {
specialTrait: string;
}
extends는 확장 시점에 타입 호환성 검사를 수행한다. 부모 인터페이스와 충돌하는 프로퍼티를 정의하면 즉시 오류가 발생한다.
interface Base {
value: string;
}
// interface Derived extends Base {
// value: number; // 오류: 'string'과 호환되지 않는 'number'
// }
type: & 인터섹션
type Animal = {
name: string;
sound(): string;
};
type Pet = Animal & {
owner: string;
vaccinated: boolean;
};
// 충돌 감지 시점이 다름
type Base = { value: string };
type Derived = Base & { value: number };
// Derived.value는 string & number = never
// 오류가 즉시 발생하지 않고 사용 시점에서 never로 감지됨
실무 차이점: extends는 확장 시점에 호환성을 검사하므로 더 빠른 오류 감지가 가능하다. &는 더 유연하지만 충돌 감지가 늦을 수 있다.
선언 병합: interface만 가능
이것이 두 도구의 가장 큰 기술적 차이다.
interface 선언 병합
// 여러 파일이나 같은 파일에서 같은 이름으로 선언 가능
interface UserConfig {
theme: "light" | "dark";
}
interface UserConfig {
language: "ko" | "en" | "ja";
}
interface UserConfig {
notifications: boolean;
}
// 자동으로 합쳐짐
const config: UserConfig = {
theme: "dark",
language: "ko",
notifications: true,
};
type은 병합 불가
type UserSettings = { theme: "light" | "dark" };
// type UserSettings = { language: string }; // 오류: 중복 식별자
선언 병합의 대표 활용: 라이브러리 타입 보강(Module Augmentation)
// 외부 라이브러리의 타입을 건드리지 않고 확장
// types/express-session/index.d.ts
import "express-session";
declare module "express-session" {
interface SessionData {
userId?: string;
cartItems?: string[];
lastVisited?: Date;
}
}
// 이후 session.userId로 타입 안전하게 접근 가능
import { Request } from "express";
function getCart(req: Request): string[] {
return req.session.cartItems ?? [];
}
재귀 타입
재귀 타입은 자기 자신을 참조하는 타입이다. type이 더 자연스럽게 표현된다.
type으로 재귀 타입
// JSON 값 타입 (재귀)
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
const data: JsonValue = {
name: "Alice",
age: 30,
tags: ["ts", "dev"],
address: {
city: "Seoul",
zip: "12345",
},
};
// 트리 구조
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};
const tree: TreeNode<string> = {
value: "root",
children: [
{
value: "branch-1",
children: [
{ value: "leaf-1-1", children: [] },
{ value: "leaf-1-2", children: [] },
],
},
{ value: "branch-2", children: [] },
],
};
interface로 재귀 타입 (제한적)
// interface로도 가능하지만 직접 재귀는 조금 더 장황함
interface TreeNodeInterface<T> {
value: T;
children: TreeNodeInterface<T>[];
}
// JSON 같은 복잡한 재귀 유니온은 interface로 표현하기 어렵다
// interface JsonValue { ... } 로는 | null | boolean ... 유니온을 표현할 수 없음
언제 interface? 언제 type? — 실무 기준 가이드라인
interface를 선택해야 할 때
1. 공개 API의 객체 구조를 정의할 때
라이브러리나 모듈의 공개 인터페이스는 interface로 정의한다. 사용자가 선언 병합으로 확장할 수 있어야 하기 때문이다.
// 라이브러리 코드
export interface PluginConfig {
name: string;
version: string;
}
// 사용자 코드에서 확장 가능
declare module "my-library" {
interface PluginConfig {
customField: string;
}
}
2. 클래스의 계약(contract)을 정의할 때
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<boolean>;
}
class UserRepository implements Repository<User> {
// 모든 메서드를 구현하지 않으면 컴파일 오류
async findById(id: string): Promise<User | null> { ... }
async findAll(): Promise<User[]> { ... }
async save(user: User): Promise<User> { ... }
async delete(id: string): Promise<boolean> { ... }
}
3. 객체 지향 계층 구조를 표현할 때
interface Shape {
area(): number;
perimeter(): number;
}
interface TwoDShape extends Shape {
x: number;
y: number;
}
interface ThreeDShape extends Shape {
volume(): number;
}
type을 선택해야 할 때
1. 유니온이나 인터섹션 타입을 정의할 때
// interface로는 불가
type Status = "active" | "inactive" | "pending";
type ID = string | number;
type Result<T> = { success: true; data: T } | { success: false; error: string };
2. 원시 타입 또는 튜플에 이름을 붙일 때
type Timestamp = number; // Unix 타임스탬프
type RGB = [number, number, number];
type HSL = [number, number, number];
type Pair<A, B> = [A, B];
3. 유틸리티 타입, 조건부 타입, mapped 타입을 조합할 때
// 조건부 타입 — interface로 불가
type NonNullable2<T> = T extends null | undefined ? never : T;
// Mapped 타입
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// 복잡한 조합
type ApiEndpoints = {
[K in keyof UserService as `/${Lowercase<string & K>}`]: UserService[K];
};
4. 재귀 타입이 필요할 때
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
클래스에서의 사용: implements는 둘 다 가능
클래스는 interface와 객체 형태의 type 모두를 implements로 사용할 수 있다.
interface Printable {
print(): void;
}
type Saveable = {
save(): Promise<void>;
};
// 둘 다 implements 가능
class Document implements Printable, Saveable {
print(): void {
console.log("문서 출력");
}
async save(): Promise<void> {
console.log("문서 저장");
}
}
단, 유니온 타입은 implements할 수 없다.
type StringOrNumber = string | number;
// class MyClass implements StringOrNumber {} // 오류: 유니온은 implements 불가
실전 예제: 라이브러리 타입 확장(interface), 복잡한 유니온(type)
라이브러리 타입 확장 (interface 활용)
// Axios 인터셉터에 커스텀 메타데이터 추가
// types/axios/index.d.ts
import "axios";
declare module "axios" {
interface AxiosRequestConfig {
_retryCount?: number;
_startTime?: number;
skipAuth?: boolean;
cacheKey?: string;
}
interface AxiosResponse {
duration?: number; // 응답 시간 (ms)
}
}
// 실제 사용 코드
import axios from "axios";
axios.interceptors.request.use((config) => {
config._startTime = Date.now();
config._retryCount = 0;
return config;
});
axios.interceptors.response.use((response) => {
if (response.config._startTime) {
response.duration = Date.now() - response.config._startTime;
}
return response;
});
복잡한 유니온 상태 관리 (type 활용)
// Redux-like 액션 타입
type UserAction =
| { type: "USER_FETCH_START" }
| { type: "USER_FETCH_SUCCESS"; payload: User }
| { type: "USER_FETCH_ERROR"; error: string }
| { type: "USER_UPDATE"; payload: Partial<User> }
| { type: "USER_DELETE"; id: string }
| { type: "USER_LOGOUT" };
type AuthAction =
| { type: "AUTH_LOGIN"; payload: { token: string; user: User } }
| { type: "AUTH_LOGOUT" }
| { type: "AUTH_TOKEN_REFRESH"; token: string };
type AppAction = UserAction | AuthAction;
// 리듀서: 모든 케이스 강제 처리
function userReducer(state: User | null, action: UserAction): User | null {
switch (action.type) {
case "USER_FETCH_SUCCESS":
return action.payload;
case "USER_UPDATE":
return state ? { ...state, ...action.payload } : null;
case "USER_DELETE":
case "USER_LOGOUT":
return null;
case "USER_FETCH_START":
case "USER_FETCH_ERROR":
return state;
}
}
고수 팁: 팀 컨벤션 설정과 ESLint 규칙
TypeScript 공식 권장사항
TypeScript 팀의 공식 입장: "어떤 것을 사용해도 괜찮지만, 일관성이 중요하다." 다만 interface를 기본으로 사용하고, type이 필요한 경우에만 사용하는 것을 권장한다(TypeScript 코드베이스 자체도 이 방식을 따른다).
ESLint 규칙으로 컨벤션 강제
// .eslintrc.json 또는 eslint.config.js
{
"rules": {
// "interface-over-type": interface를 기본으로 강제
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
// 또는 type을 기본으로 강제
"@typescript-eslint/consistent-type-definitions": ["error", "type"]
}
}
프로젝트별 추천 전략
오픈소스 라이브러리 / SDK 개발:
- 공개 타입은
interface(사용자가 확장 가능해야 함) - 내부 구현 타입은
type자유롭게 사용
일반 애플리케이션 개발:
- 도메인 모델(User, Order 등)은
interface - 유틸리티·유니온·상태 타입은
type
팀 내 혼란을 줄이는 실용적 규칙:
// 규칙 1: 선언 병합이 필요한 상황에서만 interface 필수
// 규칙 2: 유니온·인터섹션·리터럴은 type 필수
// 규칙 3: 그 외에는 팀이 합의한 하나로 통일
// tsconfig.json에 strict 모드 활성화로 타입 안전성 확보
// tsconfig.json
{
"compilerOptions": {
"strict": true, // 엄격 타입 검사
"noImplicitAny": true, // any 암시적 사용 금지
"strictNullChecks": true, // null/undefined 엄격 검사
"noUncheckedIndexedAccess": true, // 인덱스 접근 undefined 포함
"exactOptionalPropertyTypes": true // optional 프로퍼티 엄격 처리
}
}
정리 표
| 상황 | 권장 도구 | 이유 |
|---|---|---|
| 객체 구조 정의 (일반) | interface | 가독성, 확장 용이 |
| 공개 API 타입 | interface | 선언 병합으로 사용자 확장 가능 |
| 클래스 계약 | interface | implements 의도 명확 |
| 유니온 타입 | type | interface로 표현 불가 |
| 인터섹션/믹스인 | type 또는 interface extends | 상황에 따라 선택 |
| 원시 타입 별칭 | type | interface로 불가 |
| 튜플 타입 | type | interface로 표현 어색 |
| 조건부/mapped 타입 | type | interface로 불가 |
| 재귀 타입 | type | 더 자연스러운 표현 |
| 라이브러리 타입 보강 | interface (선언 병합) | type으로 불가 |
다음 장에서는...
3.4 인덱스 시그니처와 Record에서는 동적 키를 가진 객체를 타입 안전하게 다루는 방법을 살펴본다. [key: string]: T 인덱스 시그니처와 Record<K, V> 유틸리티 타입의 차이, 그리고 noUncheckedIndexedAccess 컴파일러 옵션으로 런타임 오류를 컴파일 타임에 잡는 방법을 다룬다.