TypeScript interface vs type, 뭐가 다를까? 실전 심층 분석
•📖 4분 소요
TypeScriptinterfacetype alias선언 병합구조적 타이핑
본문 시작
요즘 코드 리뷰에서 “이건 interface가 맞아? type이 맞아?”라는 말 자주 듣지? 사실 둘 다 객체의 “모양(shape)”을 설명한다는 점에서 80%는 겹쳐. 하지만 남은 20%가 라이브러리 설계나 DX에 꽤 큰 차이를 만든다. 오늘은 겹치는 점은 빠르게, 중요한 차이는 깊게, 그리고 실전에선 어떻게 고르는지까지 정리해봤어.
⸻
1) 둘 다 ‘모양’을 말한다 — 공통점 빠르게 훑기
TypeScript는 구조적 타이핑이라 “이름”보다 “구조”가 맞는지가 중요해. 그래서 interface도 type alias도 아래처럼 대부분 같은 걸 표현할 수 있어.
// 객체 형태
interface UserI {
id: string;
name?: string;
readonly role: 'admin' | 'user';
}
type UserT = { id: string; name?: string; readonly role: 'admin' | 'user' };
// 호출/생성 시그니처
interface MakeI {
(n: number): string;
}
type MakeT = { (n: number): string };
// 인덱스 시그니처
interface DictI {
[key: string]: number;
}
type DictT = { [key: string]: number };
// 클래스에서 구현도 가능(객체 타입이면)
type PointLike = { x: number; y: number };
class P implements PointLike {
x = 0;
y = 0;
}
핵심: “객체 타입”을 표현하는 한에서 interface와 type은 거의 대체 가능하다는 거야.
2) 진짜 차이 6가지 — 설계에 영향을 주는 포인트
- 선언 병합(Declaration Merging): interface만 된다 type은 “이름 재선언”이 불가능하지만, interface는 같은 이름으로 열어서 속성을 합칠 수 있어. 외부 라이브러리 타입 보강이나 점진적 확장에 특히 유용하지.
interface Request {
id: string;
}
interface Request {
traceId?: string;
} // 병합됨
const r: Request = { id: '1', traceId: 't-42' };
// type은 안 됨
type R = { id: string };
// type R = { traceId?: string } // ❌ Duplicate identifier 'R'
- 표현력(Expressiveness): type이 더 넓다 type alias는 객체 타입뿐 아니라 union, primitive, tuple, 템플릿 리터럴, 조건부/매핑 타입까지 표현 가능해. interface는 “객체 형태”에 초점이야.
type ID = string | number;
type Pair = [number, string];
type Route = `/${string}`;
type ReadonlyKeys<T> = {
readonly [K in keyof T]: T[K];
};
- 매핑/키 리매핑: type만 가능 인터페이스 본문 안에서는 mapped type 구문([K in ...], as 리매핑)을 쓸 수 없어. 이런 변환이 필요하면 type을 써.
type ApiResponse<T> = {
[K in keyof T as `data_${Extract<K, string>}`]: T[K];
};
// interface로는 같은 형태를 직접 만들 수 없음
- 확장 방식: extends vs 교차(Intersection) interface는 extends로, type은 보통 & 교차로 확장해. 겹치는 속성이 충돌할 때의 동작이 다르게 느껴질 수 있어.
type A = { x: string };
type B = { x: number };
// 교차 결과: x는 string & number -> never가 되어 사실상 구현 불가
type I = A & B; // x: never
interface E extends A, B {} // ❌ 'x' 타입 충돌 오류를 즉시 보고
정리하면: 둘 다 “충돌은 문제”지만, type 교차는 결과 타입에 never를 만들어 “불가능한 조합”이 되고, interface extends는 “즉시 오류”로 드러나는 경향이 있어. API 설계 시 피드백 타이밍이 달라져.
- 클래스와의 관계: interface extends class 패턴 interface는 “클래스(인스턴스 타입)를 상속”할 수 있고, private/protected 멤버가 있으면 그 클래스를 상속한 애만 구현 가능하게 제한할 수 있어. 권한 있는 구현만 허용하는 ‘브랜딩’ 트릭으로 자주 써.
class Base {
private brand = 'secret';
ping() {}
}
interface Branded extends Base {
pong(): void;
}
class Good extends Base implements Branded {
pong() {}
}
class Bad implements Branded {
// ❌ private 'brand'가 없어서 구현 불가
pong() {}
}
(type 교차로도 비슷하게 만들 수 있지만, 의도를 드러내고 에러 메시지가 친절한 쪽은 보통 interface 패턴이야.)
- 모듈 보강(Module Augmentation): interface만 실전급 전역/모듈 타입을 열어 확장하는 건 interface가 표준 루트야. 타사 SDK에 안전하게 훅을 넣을 때 주로 이렇게 해.
declare global {
interface Window {
myAnalytics?: { track(event: string): void };
}
}
window.myAnalytics?.track('opened');
3) 코드로 보는 선택 기준 — 상황별 스니펫
- 라이브러리의 퍼블릭 API, 확장 가능한 도메인 모델: interface 추천 추후 확장을 선언 병합으로 받아들이기 수월해.
export interface Payment {
id: string;
amount: number;
}
- 파생/변환 타입, 유니온 기반 모델링, DTO 변환: type 추천 매핑/조건부/템플릿 리터럴로 강력한 변형을 만들 수 있어.
type Entity<T extends { id: string }> = T & { createdAt: Date };
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
type ResponseFields<T> = {
[K in keyof T as `data_${Extract<K, string>}`]: T[K];
};
- 함수 오버로드 시그니처: 둘 다 가능, 팀 컨벤션 따르기
interface OverI {
(x: string): number;
(x: number): string;
}
type OverT = {
(x: string): number;
(x: number): string;
};
- 클래스 implements 대상: “객체 타입”이면 interface/type 모두 OK. 단, 유니온/프리미티브는 implements 불가.
type Shape = { area(): number } | { length(): number };
// class C implements Shape {} // ❌ 유니온은 구현 대상이 아님
4) 실전 팁과 함정 체크리스트
- 팀 규칙으로 “객체의 공개 API는 interface, 변환/유니온/매핑은 type”을 두면 고민이 줄어들어.
- 교차 타입(A & B) 남용은 컴파일 타임 복잡도와 에러 메시지 가독성을 해칠 수 있어. 설계상 실제로 “둘 다 반드시 만족”이 맞는지 다시 생각해 보자.
- 선택적 속성(?, optional) 충돌, readonly 충돌은 초기 설계에서 빨리 드러나게 하는 게 좋아. interface extends를 쓰면 충돌을 바로 오류로 띄우기 쉬워.
- 라이브러리에서 사용자 확장을 의도했다면 interface 공개 + 모듈 보강 예시를 문서화하자.
- 리터럴 객체 검증엔 satisfies를 곁들이면 “과잉 속성 검사”를 유지하면서 별칭 형태와 무관하게 타입 안전을 챙길 수 있어.
const routes = {
home: '/',
about: '/about',
} as const satisfies Record<string, `/${string}`>;
5) 한눈에 요약 — 결정 트리
- 객체의 퍼블릭 계약(확장 필요) → interface
- 유니온/튜플/템플릿/조건부/매핑 같은 고급 타입 변환 → type
- 클래스 인스턴스에만 구현 허용하고 싶다 → interface extends class 패턴
- 외부 타입 보강/전역 확장 → interface + module augmentation
- 애매하면? 객체 “모양만” 필요하면 interface부터, 변환 필요해지면 type 도입
마무리
interface와 type은 “대부분 같지만, 설계의 방향을 바꾸는 몇 가지 차이”가 있어. API는 안정적으로 열어두고, 변환은 유연하게 날리면 베스트 조합이 나온다. 다음 PR에서 딱 한 줄만 더 고민하면, 타입 설계의 톤이 한 단계 올라간다.