문법 차이를 넘어 선언 병합, 확장 방식, 성능, 실무 선택 기준까지 type과 interface의 차이가 뭘까
TypeScript를 쓰다 보면 한 번쯤 멈추는 지점이 있다.
"여기서 type을 써야 하나, interface를 써야 하나?"
공식 문서는 둘 다 쓸 수 있다고 말한다. 팀마다 컨벤션이 다르고, 의견도 갈린다. 하지만 두 가지가 완전히 동일하진 않다.
표면적인 문법 차이는 금방 정리된다. 진짜 문제는 어떤 상황에서 어느 쪽이 더 적합한가다.
// interface
interface User {
id: number;
name: string;
}
// type
type User = {
id: number;
name: string;
};객체 형태를 정의하는 것만 보면 차이가 없다. 둘 다 같은 역할을 한다.
하지만 type은 객체 외에도 다양한 것을 표현할 수 있다.
type ID = string | number;
type Point = [number, number];
type Callback = () => void;
type Nullable<T> = T | null;interface는 이런 표현이 불가능하다. 오직 객체 형태만 정의할 수 있다.
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}extends는 상속 구조가 명확하고, 컴파일러가 충돌을 감지한다.
interface A {
value: string;
}
interface B extends A {
value: number; // 오류: 'number'는 'string'에 할당 불가
}충돌이 있으면 명시적으로 에러가 난다. 안전하다.
type Animal = {
name: string;
};
type Dog = Animal & {
breed: string;
};Intersection은 두 타입을 합친다. 확장보다 조합에 가깝다.
주의할 점이 있다. 같은 키가 충돌하면 에러가 나지 않고 never가 된다.
type A = { value: string };
type B = { value: number };
type C = A & B;
// C.value는 string & number → never에러 없이 never가 되기 때문에 조용히 버그로 이어질 수 있다.

interface에만 있는 기능이다.
같은 이름의 interface를 여러 번 선언하면 자동으로 합쳐진다.
interface Config {
debug: boolean;
}
interface Config {
timeout: number;
}
// 실제로는 이 타입이 된다
// interface Config {
// debug: boolean;
// timeout: number;
// }type으로 같은 이름을 두 번 선언하면 에러다.
type Config = { debug: boolean };
type Config = { timeout: number }; // 오류: 중복 식별자라이브러리 타입을 확장할 때 자주 활용된다.
// express의 Request 타입에 user 프로퍼티 추가
declare namespace Express {
interface Request {
user?: AuthUser;
}
}라이브러리 소스를 건드리지 않고 타입을 주입할 수 있다. interface이기 때문에 가능한 패턴이다.
반대로 라이브러리 내부에서도 의도적으로 interface를 쓰는 이유가 여기에 있다. 사용자가 타입을 확장할 수 있게 열어두는 것이다.

둘 다 제네릭을 지원한다.
interface ApiResponse<T> {
data: T;
status: number;
}
type ApiResponse<T> = {
data: T;
status: number;
};기능 차이는 없다. 하지만 조건부 타입은 type에서만 가능하다.
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"interface는 조건부 타입 자체를 표현할 수 없다.
type만 가능한 영역제네릭보다 더 강력한 표현이 필요할 때 Mapped Types가 등장한다.
[K in keyof T] 문법으로 기존 타입의 각 키를 순회하며 새 타입을 만드는 방식이다.
type OptionsFlags<T> = {
[K in keyof T]: boolean;
};
type Features = {
darkMode: () => void;
newUserProfile: () => void;
};
type FeatureOptions = OptionsFlags<Features>;
// { darkMode: boolean; newUserProfile: boolean; }interface로는 이 문법을 쓸 수 없다.
// 오류: interface는 mapped type 문법 지원 안 함
interface OptionsFlags<T> {
[K in keyof T]: boolean; // Error
}컴파일러 제약이다. 의견의 문제가 아니다.
modifier를 붙여 기존 타입을 변환하는 것도 type만 가능하다.
// readonly 제거
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// optional 제거
type Required<T> = {
[K in keyof T]-?: T[K];
};
// 키 이름 변환 (as로 remapping)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};이 계열의 작업은 모두 type이 담당한다.
Pick, Omit, Partial, Required, Readonly, Record 같은 내장 유틸리티 타입은 type과 interface 모두 입력으로 받는다.
interface User {
id: number;
name: string;
email: string;
}
type PartialUser = Partial<User>; // 동작
type PickedUser = Pick<User, 'id'|'name'>; // 동작
type OmitUser = Omit<User, 'email'>; // 동작주의할 점은 유틸리티 타입의 **결과는 항상 type**이라는 것이다.
그 결과를 다시 interface extends로 확장하고 싶다면 중간 type을 거쳐야 한다.
type PartialUser = Partial<User>;
interface AdminUser extends PartialUser {
role: string;
}Record<string, string> 같은 타입에 할당할 때 interface와 type이 다르게 동작하는 경우가 있다.
interface Named { name: string }
type NamedType = { name: string }
const a: Record<string, string> = {} as Named; // 오류
const b: Record<string, string> = {} as NamedType; // 동작interface는 명시적으로 인덱스 시그니처를 선언하지 않으면 암묵적인 인덱스 시그니처를 갖지 않는다. type은 갖는다. 제네릭 함수나 유틸리티 타입과 조합할 때 예상치 못한 오류로 이어질 수 있다.
둘 다 재귀 타입을 지원하지만, 역사적으로 type이 더 유연했다.
type TreeNode = {
value: number;
children: TreeNode[];
};
interface TreeNode {
value: number;
children: TreeNode[];
}현재 TypeScript(4.1 이후)에서는 둘 다 잘 동작한다. 그러나 복잡한 재귀 구조에서는 type이 더 직관적인 경우가 많다.
interface는 클래스가 구현해야 할 계약(contract)을 정의하는 데 어울린다.
interface Serializable {
serialize(): string;
deserialize(data: string): void;
}
class UserStore implements Serializable {
serialize() {
return JSON.stringify(this);
}
deserialize(data: string) {
Object.assign(this, JSON.parse(data));
}
}type도 implements에 사용할 수 있다. 하지만 유니온 타입은 클래스가 구현할 수 없다.
type StringOrNumber = string | number;
class Foo implements StringOrNumber {} // 오류클래스 계약을 표현할 때는 interface가 명확하다.
TypeScript 공식 팀의 언급이 있다.
interface는 캐시가 가능한 named type이다. type alias는 항상 확장 시 재계산된다.
실제로 복잡한 타입 계층에서 interface extends는 컴파일러가 결과를 캐싱할 수 있어 더 빠르다. type & Intersection은 매번 재계산이 필요하다.
대부분의 프로젝트에서 체감할 수 있는 차이는 아니다. 하지만 타입 수백 개가 얽힌 대형 프로젝트에서는 interface 기반이 컴파일 속도에 유리하다.

정리하면 이렇게 나뉜다.
| 상황 | 권장 |
|---|---|
| 객체 구조 정의 (확장 가능성 있음) | interface |
| 유니온, 튜플, 원시 타입 별칭 | type |
| 클래스 계약 (implements) | interface |
| 조건부 타입, 매핑 타입 | type |
| 라이브러리 타입 확장 | interface |
| 복잡한 타입 조합 / 유틸리티 | type |
| 함수 타입 | type (더 간결) |
팀 컨벤션이 없다면 이 기준이 가장 실용적이다.
기본 객체 구조는 interface로 시작한다.
확장 가능성이 있고, 선언 병합이 필요할 수 있는 공개 API나 라이브러리 코드는 interface가 맞다.
유니온이나 조건부 타입이 필요한 순간 type으로 간다.
interface로 표현이 안 되는 순간이 분명히 온다. 그 순간 type을 쓰면 된다.
// interface로 시작
interface ButtonProps {
label: string;
onClick: () => void;
}
// 유니온이 필요하면 type
type ButtonVariant = "primary" | "secondary" | "danger";
// 조건부가 필요하면 type
type ExtractProps<T> = T extends React.ComponentType<infer P> ? P : never;어떤 팀은 "전부 interface", 어떤 팀은 "전부 type"으로 통일하기도 한다.
둘 다 나쁘지 않다. 일관성이 있으면 된다.
하지만 TypeScript 팀의 공식 권장은 객체 타입에는 interface를 먼저 고려하고, interface로 표현이 안 될 때 type을 쓰라는 것이다.
이유는 단순하다. interface가 에러 메시지를 더 명확하게 보여주고, 선언 병합이라는 확장 가능성을 열어두기 때문이다.
TypeScript 공식 핸드북은 아래와 같이 명시한다.
"For the most part, you can choose based on personal preference, and TypeScript will tell you if it needs something to be the other kind of declaration. If you would like a heuristic, use
interfaceuntil you need to use features fromtype."
Documentation - Everyday Types
The language primitives.