TypeScript type과 interface 비교
type과 interface의 차이점과 상황별 선택 가이드
읽는 데 74분
- #typescript
- #type
- #interface
- #type-system
이 문서의 목차
적용 환경: TypeScript 4.2+
type과 interface 중 무엇을 쓸 것인가는 팀마다 반복되는 논쟁거리입니다. 객체 타입을 정의하는 가장 흔한 상황에서 둘이 사실상 동일하게 동작하는 탓에, 선택의 근거가 취향 문제로 흘러가기 쉽습니다. 문제를 키우는 것은 검색으로 접하는 조언의 상당수가 낡았다는 점입니다. 특히 "interface가 에러 메시지를 더 명확하게 보여준다"는 오래된 논거는 TypeScript 4.2에서 타입 별칭 이름이 에러 메시지에 보존되도록 바뀌면서 이미 근거를 잃었는데도 여전히 인용됩니다.
결론부터 말하면 대부분의 경우 둘 중 무엇을 쓰든 상관없으며, 각각이 명확히 유리한 상황만 구분하면 충분합니다. 객체 형태를 정의하고 확장한다면 interface가 자연스럽고, 유니온이나 튜플처럼 객체가 아닌 타입이나 타입 연산이 필요하다면 type 외에 선택지가 없습니다. 이 문서는 둘의 실제 차이를 확인 가능한 근거 위에서 정리하고, 상황별 선택 기준을 제시합니다.
객체 타입을 정의하는 가장 기본적인 상황에서는 type과 interface 모두 동일하게 동작합니다. 문법상 차이는 interface가 등호(=) 없이 바로 중괄호로 시작하고, type은 등호를 사용한다는 점뿐입니다.
interface User {
id: number;
name: string;
email: string;
}
const user: User = {
id: 1,
name: '홍길동',
email: 'hong@example.com'
};type User = {
id: number;
name: string;
email: string;
};
const user: User = {
id: 1,
name: '홍길동',
email: 'hong@example.com'
};위 두 코드는 완전히 동일하게 동작합니다. 타입 체크 결과도 같고, 에러 메시지에 이름이 노출되는 방식도 다르지 않습니다. 두 문법이 갈라지는 지점은 객체 타입 정의를 벗어난 영역에 있습니다.
아래 표는 type과 interface의 기능 차이를 요약합니다. 이후 섹션에서 각 차이점을 자세히 살펴봅니다.
| 기능 | type | interface |
|---|---|---|
| 객체 타입 정의 | ✅ | ✅ |
| 확장(상속) | & (인터섹션) | extends |
| 선언 병합 | ❌ | ✅ |
| 유니온 타입 | ✅ | ❌ |
| 튜플 타입 | ✅ | ⚠️ |
| 매핑된 타입 | ✅ | ❌ |
| 원시 타입 별칭 | ✅ | ❌ |
| 조건부 타입 | ✅ | ❌ |
표만 보면 type이 상위 호환처럼 보이지만, 지원하는 기능의 개수가 선택 기준이 되지는 않습니다. interface에는 type이 흉내 낼 수 없는 선언 병합이 있고, 이 기능은 외부 라이브러리의 타입을 확장할 때 대체재가 없습니다. 여기에 더해 공식 핸드북은 extends를 사용하는 interface가 인터섹션을 사용하는 type보다 컴파일러 관점에서 더 효율적인 경우가 많다고 안내하는데, 이 차이는 타입이 복잡해질수록 체감됩니다.
에러 메시지 논거는 4.2에서 유효기간이 끝났습니다
interface를 기본값으로 권하는 근거로 "에러 메시지가 더 명확하다"가 자주 인용됩니다. 공식 핸드북은 이를 TypeScript 4.2 이전의 제약으로 명시합니다. 그 이전에는 타입 별칭 이름이 익명 타입으로 치환되어 에러 메시지에 드러나지 않는 경우가 있었지만, 4.2부터는 별칭 이름도 그대로 보존됩니다. 현재 버전을 쓰고 있다면 이 논거는 선택의 근거가 되지 않습니다.
타입을 확장해야 하는 상황은 자주 발생합니다. 기존 타입에 새로운 속성을 추가하거나, 여러 타입을 조합해서 새로운 타입을 만들 때가 그렇습니다. interface는 객체지향 언어에서 익숙한 extends 키워드를 사용하고, type은 집합 연산의 개념인 인터섹션(&)을 사용합니다.
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// 다중 상속도 가능합니다
interface Identifiable {
id: number;
}
interface Pet extends Animal, Identifiable {
owner: string;
}type Animal = {
name: string;
};
type Dog = Animal & {
breed: string;
};
// 다중 결합도 동일하게 가능합니다
type Identifiable = {
id: number;
};
type Pet = Animal & Identifiable & {
owner: string;
};결과적으로 Dog와 Pet 타입은 두 방식 모두 동일한 구조를 갖습니다. 게다가 type과 interface는 서로를 확장할 수 있어서, 프로젝트에 두 방식이 혼용되어 있어도 조합에는 아무 문제가 없습니다.
// interface가 type을 확장
type Animal = { name: string };
interface Dog extends Animal { breed: string }
// type이 interface를 확장
interface Vehicle { wheels: number }
type Car = Vehicle & { brand: string };문법적 취향의 문제로 보이지만, 컴파일러 입장에서는 그렇지 않습니다. 공식 핸드북은 "Using interfaces with extends can often be more performant for the compiler than type aliases with intersections"라고 명시합니다. interface는 확장 관계를 선언 자체에 기록해 두고 결과를 캐싱할 수 있는 반면, 인터섹션은 사용될 때마다 구성 타입을 다시 합성해야 하기 때문입니다. 타입이 몇 개 수준일 때는 차이가 없지만, 대규모 코드베이스에서 인터섹션이 겹겹이 쌓이면 타입 체크 시간에 반영됩니다.
확장 과정에서 같은 이름의 속성이 서로 다른 타입으로 정의되는 상황은 생각보다 자주 발생하는데, 이때 두 방식의 동작이 결정적으로 갈립니다.
interface는 충돌을 컴파일 타임 에러로 즉시 차단합니다. 잘못된 확장이 선언 지점에서 걸러지므로 원인을 추적할 필요가 없습니다.
// ✅ interface: 충돌을 선언 시점에 에러로 차단
interface Base { id: number }
interface Extended extends Base {
id: string; // Error TS2430: 'string'은 'number'에 할당할 수 없습니다
}반면 type의 인터섹션은 충돌을 에러로 처리하지 않고, 두 타입의 교집합을 계산합니다. number와 string의 교집합은 존재하지 않으므로 never가 됩니다. 문제는 이 never가 조용히 적용되어, 실제로 해당 속성을 사용하려 할 때까지 문제를 모를 수 있다는 점입니다.
// ⚠️ type: 충돌 시 에러 없이 never가 됨
type Base = { id: number };
type Extended = Base & { id: string };
// Extended['id']는 number & string, 즉 never입니다
// 선언 자체는 통과하지만, never에는 어떤 값도 넣을 수 없습니다
const obj: Extended = { id: 1 };
// Error TS2322: 'number' 형식은 'never' 형식에 할당할 수 없습니다인터섹션 충돌 주의
type의 인터섹션은 속성 충돌을 에러 없이 never로 처리합니다. 복잡한 타입 조합에서 예상치 못한 never가 나타난다면 인터섹션 과정의 충돌을 먼저 의심하는 것이 좋습니다.
선언 병합(Declaration Merging)은 interface만 가지는 고유 기능입니다. 같은 이름의 interface를 여러 번 선언하면 TypeScript가 자동으로 하나로 병합해줍니다.
interface User {
id: number;
name: string;
}
interface User {
email: string;
}
interface User {
age?: number;
}
// 세 선언이 병합되어 User는 모든 속성을 가집니다
const user: User = {
id: 1,
name: '홍길동',
email: 'hong@example.com',
age: 30
};type으로 같은 이름을 재선언하면 Duplicate identifier 에러(TS2300)가 발생합니다. 의도치 않은 재선언이 조용히 병합될 수 있다는 점에서 interface의 이 동작이 위험해 보일 수 있지만, 여기에는 라이브러리 타입 확장이라는 명확한 존재 이유가 있습니다.
외부 라이브러리의 타입에 프로젝트에서 필요한 속성을 추가해야 하는 상황은 흔합니다. 인증 미들웨어가 req.user에 사용자 정보를 붙여주는 Express.js 구성이 대표적인데, 원래의 Request 타입에는 user가 없으므로 TypeScript는 이를 에러로 처리합니다. 선언 병합을 쓰면 라이브러리 코드를 건드리지 않고 타입만 확장할 수 있습니다.
// types/express.d.ts
import 'express'; // 이 파일을 모듈로 만들기 위한 import
declare global {
namespace Express {
interface Request {
user?: {
id: string;
role: string;
};
}
}
}Window 객체에 전역 변수를 추가하는 경우도 마찬가지입니다. Google Analytics의 gtag나 Redux DevTools처럼 전역 함수를 심는 도구들을 선언 병합으로 타입 시스템에 알릴 수 있습니다.
// types/global.d.ts
declare global {
interface Window {
__REDUX_DEVTOOLS_EXTENSION__?: () => any;
gtag?: (...args: any[]) => void;
}
}
export {}; // 모듈로 인식시키기 위한 빈 export두 예제에 등장하는 import와 export {}는 장식이 아닙니다. declare global은 모듈 파일 안에서만 사용할 수 있어서, 파일에 import나 export가 하나도 없으면 TypeScript가 이를 전역 스크립트로 간주하고 Augmentations for the global scope can only be directly nested in external modules or ambient module declarations(TS2669) 에러를 냅니다. 전역 선언만 담긴 .d.ts라면 파일 끝의 export {} 한 줄이 이 문제를 해결합니다. 선언 병합을 처음 적용할 때 가장 자주 걸리는 함정입니다.
이렇게 확장해 두면 애플리케이션 코드에서는 별도의 캐스팅 없이 사용할 수 있습니다.
app.get('/profile', (req, res) => {
console.log(req.user?.id); // 타입 에러 없음
});
if (window.gtag) {
window.gtag('event', 'page_view');
}라이브러리 제작자를 위한 팁
공개 라이브러리를 만든다면 사용자가 확장할 수 있는 타입은 interface로 정의하는 것이 좋습니다. 사용자가 선언 병합을 통해 자신의 프로젝트에 맞게 타입을 커스터마이징할 수 있기 때문입니다.
interface가 객체 타입 정의에 특화되어 있다면, type은 TypeScript 타입 시스템의 더 넓은 영역을 다룹니다. 유니온 타입, 튜플, 조건부 타입 등 고급 타입 기능은 type으로만 표현할 수 있습니다.
유니온 타입은 "A 또는 B" 형태를 표현하는 type의 핵심 기능이며, interface로는 문법 자체가 성립하지 않습니다. 가장 흔한 사용 사례는 특정 문자열 값만 허용하는 문자열 리터럴 유니온입니다.
type Status = 'pending' | 'approved' | 'rejected';
function updateStatus(status: Status) {
// 'pending', 'approved', 'rejected' 외의 값은 컴파일 에러
}
updateStatus('pending'); // ✅
updateStatus('invalid'); // ❌ Error더 복잡한 패턴으로 Discriminated Union(판별 유니온)이 있습니다. API 응답처럼 성공과 실패가 다른 구조를 가질 때 유용합니다.
type Result<T> =
| { success: true; data: T }
| { success: false; error: Error };
function handleResult(result: Result<User>) {
if (result.success) {
// TypeScript가 이 블록에서 result.data의 존재를 알고 있습니다
console.log(result.data.name);
} else {
// 이 블록에서는 result.error가 존재함을 알고 있습니다
console.error(result.error.message);
}
}여기서 success 속성이 판별자(discriminant) 역할을 합니다. TypeScript는 if 조건을 분석해 어떤 유니온 멤버인지 추론하고, 해당 멤버의 속성만 자동완성으로 제안합니다. 이 패턴을 상태 설계 전반으로 확장하는 방법은 Discriminated Union으로 상태 관리 설계하기에서 별도로 다룹니다.
interface는 객체 타입만 정의할 수 있지만, type은 원시 타입에도 별칭을 붙일 수 있습니다.
type ID = string;
type Age = number;
type IsActive = boolean;단순한 별칭은 코드 가독성을 높여주지만, 타입 안전성 측면에서는 별 도움이 되지 않습니다. ID와 string은 완전히 호환되기 때문입니다. 더 강력한 타입 안전성이 필요하다면 브랜디드 타입(Branded Type) 패턴을 사용할 수 있습니다.
type UserId = string & { readonly brand: unique symbol };
type ProductId = string & { readonly brand: unique symbol };
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
// UserId와 ProductId는 둘 다 런타임에서는 string이지만,
// 타입 시스템에서는 서로 호환되지 않습니다
const userId = 'user-123' as UserId;
const productId = 'product-456' as ProductId;
getUser(userId); // ✅
getUser(productId); // ❌ Error: ProductId는 UserId에 할당할 수 없음튜플은 고정된 길이와 각 위치별 타입이 정해진 배열입니다. 좌표, RGB 색상, 함수의 다중 반환값 등을 표현할 때 유용합니다.
type Point = [number, number];
type RGB = [number, number, number];
const origin: Point = [0, 0];
const red: RGB = [255, 0, 0];
// TypeScript 4.0+에서는 각 요소에 이름을 붙일 수 있습니다
type Coordinate = [x: number, y: number, z?: number];
const point2D: Coordinate = [10, 20];
const point3D: Coordinate = [10, 20, 30];React의 useState 훅 반환값이 대표적인 튜플 예시입니다. 실제 시그니처는 setter가 업데이터 함수도 받아 [T, Dispatch<SetStateAction<T>>]이지만, 구조를 단순화하면 다음과 같은 형태입니다.
type UseStateReturn<T> = [T, (value: T) => void];interface로도 인덱스 시그니처와 length를 직접 나열하면 비슷한 형태를 만들 수는 있습니다. 다만 문법이 장황할 뿐 아니라 배열 메서드가 빠져 있어 실질적인 대안이 되지 못합니다.
// interface로 튜플 흉내내기 (비추천)
interface TuplePoint {
0: number;
1: number;
length: 2;
}
// type이 훨씬 간결하고, 배열 메서드도 그대로 사용할 수 있습니다
type Point = [number, number];TypeScript의 유틸리티 타입들(Partial, Readonly, Pick 등)은 모두 type으로 구현됩니다. 이 타입들은 기존 타입을 변환해서 새로운 타입을 만드는 매핑된 타입(Mapped Types)을 사용합니다.
// 모든 속성을 선택적으로 만드는 Partial의 구현
type Partial<T> = {
[P in keyof T]?: T[P];
};
// 모든 속성을 읽기 전용으로 만드는 Readonly의 구현
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};조건부 타입(Conditional Types)은 타입 수준의 if-else입니다. 입력 타입에 따라 다른 타입을 반환합니다.
// T가 배열이면 요소 타입을, 아니면 never를 반환
type ElementType<T> = T extends (infer U)[] ? U : never;
type A = ElementType<string[]>; // string
type B = ElementType<number>; // never
// Promise를 재귀적으로 언래핑
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type C = Awaited<Promise<Promise<string>>>; // string여기까지가 interface로는 접근할 수 없는 영역입니다. 라이브러리를 만들거나 도메인 타입에서 파생 타입을 뽑아내야 하는 순간에는 선택의 여지 없이 type을 쓰게 되며, 이것이 두 문법을 대립 구도로 볼 필요가 없는 이유이기도 합니다.
지금까지의 차이를 실제 코드에서 마주치는 상황으로 옮기면 판단 기준은 비교적 단순해집니다.
interface를 선택하는 경우
객체의 형태(shape)를 정의할 때 interface가 적합합니다. 특히 다음 상황에서 권장됩니다.
1. API 요청/응답 타입
서버와 주고받는 데이터 구조는 대부분 객체 형태이므로, 공통 필드를 뽑아 extends로 재사용하기 좋습니다.
interface CreateUserRequest {
name: string;
email: string;
}
interface CreateUserResponse {
user: User;
token: string;
}2. 클래스가 구현할 계약
클래스가 특정 메서드를 반드시 구현하도록 강제할 때 interface가 자연스럽습니다.
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
}
class UserRepository implements Repository<User> {
// 모든 메서드를 구현해야 합니다
}3. 확장 가능한 공개 API
라이브러리를 만들 때 사용자가 확장할 여지를 남기려면 interface로 정의해야 합니다. 선언 병합이 없으면 사용자는 타입을 커스터마이징할 방법 자체가 없습니다.
type을 선택하는 경우
interface로는 표현할 수 없는 타입이 필요할 때 type을 사용합니다.
1. 유니온 타입이 필요할 때
상태 머신, 판별 유니온, 리터럴 타입 등 "A 또는 B" 패턴이 필요하면 type이 필수입니다.
type Status = 'idle' | 'loading' | 'success' | 'error';
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };2. 튜플이 필요할 때
고정 길이 배열, 다중 반환값 등에 튜플을 사용합니다.
type Coordinate = [x: number, y: number];
type UseStateReturn<T> = [T, (value: T) => void];3. 유틸리티 타입을 만들 때
매핑된 타입, 조건부 타입 등 타입 연산이 필요하면 type만 가능합니다.
type Nullable<T> = T | null;
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};React 개발에서 type과 interface 선택은 특히 자주 고민되는 부분입니다. 컴포넌트 Props, 상태, Context 등 다양한 곳에서 타입을 정의해야 하기 때문입니다.
컴포넌트 Props는 interface로 정의하는 편이 낫습니다. Props는 본질적으로 객체 형태이고, HTML 속성 타입이나 상위 컴포넌트의 Props를 extends로 이어받는 일이 잦기 때문입니다. 이때 속성 이름이 겹치면 interface는 선언 지점에서 에러로 막아 주지만, 인터섹션은 앞서 살펴본 대로 조용히 never를 만듭니다.
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
function Button({
variant = 'primary',
size = 'md',
disabled = false,
onClick,
children
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}HTML 기본 속성까지 모두 지원하고 싶다면 React의 내장 타입을 확장합니다. 이때 커스텀 속성과 HTML 속성이 충돌할 수 있으므로 Omit으로 충돌을 방지합니다.
interface ButtonProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'size' // HTML button의 size와 커스텀 size가 충돌하므로 제외
> {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg'; // 커스텀 size 사용
}로딩 중, 성공, 실패처럼 각 상태가 서로 다른 데이터를 동반한다면 판별 유니온이 가장 적합한 패턴이며, 유니온인 이상 type으로만 표현할 수 있습니다. 옵셔널 필드를 나열하는 방식과 무엇이 다른지는 Discriminated Union으로 상태 관리 설계하기에서 자세히 비교합니다.
type AuthState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'authenticated'; user: User }
| { status: 'error'; error: string };
function useAuth() {
const [state, setState] = useState<AuthState>({ status: 'idle' });
const login = async (credentials: LoginCredentials) => {
setState({ status: 'loading' });
try {
const user = await authService.login(credentials);
setState({ status: 'authenticated', user });
} catch (e) {
setState({ status: 'error', error: (e as Error).message });
}
};
return { state, login };
}이 패턴의 진짜 가치는 소비하는 쪽에서 드러납니다. switch로 분기하면 TypeScript가 각 분기에서 사용 가능한 속성을 자동으로 좁혀 주므로, 존재하지 않는 필드를 참조하는 코드는 애초에 작성할 수 없습니다.
function Profile() {
const { state } = useAuth();
switch (state.status) {
case 'idle':
return <LoginPrompt />;
case 'loading':
return <Spinner />;
case 'authenticated':
// TypeScript가 여기서 state.user의 존재를 알고 있습니다
return <UserProfile user={state.user} />;
case 'error':
// 여기서는 state.error가 있음을 압니다
return <ErrorMessage message={state.error} />;
}
}앞의 기준을 팀 규칙으로 고정해 두면 리뷰마다 같은 논쟁이 반복되지 않습니다. 하나의 컴포넌트를 예로 들면, 실제 코드에서는 두 문법이 이렇게 자연스럽게 공존합니다.
// Props는 객체 형태이므로 interface
interface ButtonProps {
variant: ButtonVariant;
onClick: () => void;
}
// 값의 집합을 제한하는 유니온은 type
type ButtonVariant = 'primary' | 'secondary' | 'danger';
// 상태별로 데이터가 다르면 판별 유니온, 따라서 type
type LoadingState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };핵심 정리
-
객체 타입의 기본값은 interface: 확장 관계를
extends로 드러낼 수 있고, 속성 충돌이 선언 시점에 에러로 잡히며, 인터섹션보다 컴파일러에 유리합니다. 공식 핸드북의 휴리스틱도 "type의 기능이 필요해질 때까지는interface"입니다. -
type이어야만 하는 경우가 분명히 있습니다: 유니온, 튜플, 매핑된 타입, 조건부 타입은interface로 표현할 방법이 없습니다. 이때는 선택이 아니라 요구사항입니다. -
낡은 논거는 걷어내야 합니다: "에러 메시지가 더 명확하다"는 이유는 TypeScript 4.2 이후로 성립하지 않습니다. 근거가 사라진 규칙을 관성으로 유지하면 팀 컨벤션을 설명할 수 없게 됩니다.
| 상황 | 권장 |
|---|---|
| React Props | interface |
| API 요청/응답 | interface |
| 클래스 구현 계약 | interface |
| 라이브러리 공개 API | interface |
| 유니온 타입 | type |
| 튜플 | type |
| 유틸리티/헬퍼 타입 | type |
| 복잡한 타입 연산 | type |
관련 문서
글쓴이 mirunamu00



