개요
TypeScript에서는 type
과 interface
두 가지 방식으로 타입을 정의할 수 있습니다.
둘의 차이점은 무엇일까요? 어떤 상황에서 어떤 것을 사용해야 할까요?
이 글에서는 type
과 interface
의 사용법과 차이점 뿐만 아니라,
무엇이 권장되는지, 또 IDE에서는 어떤 점이 다른 지 등 재밌는 내용을 다뤄보겠습니다.
표현법과 특징
type
type은 다양한 형태의 타입 정의를 가능하게 하는 TypeScript의 기능입니다.
그냥 모든 타입을 정의할 수 있습니다.
type은 단순히 객체의 구조를 정의할 뿐만 아니라, 유니언 타입, 교차 타입, 함수 타입, 배열 타입 등
다양한 형태의 타입을 정의하는 데 사용할 수 있습니다.
// 객체의 구조를 정의할 수 있습니다. (배열도 물론!)
type MyObject = {
name: string;
age: number;
};
// 함수 타입을 정의할 수 있습니다.
type MyFunction = (a: number, b: number) => number;
- 특징
- 유니언 타입(
|
)과 교차 타입(&
)을 사용할 수 있습니다. - 타입 별칭
type alias
을 사용할 수 있습니다. - 복잡한 타입(고급 타입 조합, 매핃 타입 등)을 정의할 수 있습니다.
interface
로 정의할 수 있는 모든 타입은type
으로도 정의할 수 있습니다.
- 유니언 타입(
interface
interface는 객체의 구조를 정의하는 데 특화된 기능입니다. 반대로 얘기하면 모든 경우에 type 을 대체할 수 없다는 거죠. 객체가 어떤 프로퍼티를 가져야 하고, 해당 프로퍼티들이 어떤 타입을 가져야 하는지 명확히 설명할 수 있습니다.
interface는 클래스와 상호작용하거나, 구현하는 구조를 정의하는 데 자주 사용됩니다.
// 객체의 구조를 정의할 수 있습니다.
interface Person {
name: string;
age: number;
}
// 함수 타입을 정의할 수 있습니다.
interface GreetFunction {
(name: string): string;
}
- 특징
- 상속(
extends
)를 통해 확장이 가능합니다. - 클래스를 통해 구현될 수 있고, 클래스가 특정 구조를 따르도록 강제할 수 있습니다.
- 동일한 이름의 인터페이스가 여러 번 선언되면, TypeScript는 자동으로 병합합니다.(
선언 병합
) type
에 비해 더 제한적입니다.
- 상속(
type의 기능
interface
에서는 안 되는, type
만의 기능에 대해 알아보겠습니다. 거기에 interface
를 type
과 함께 쓰는 방법도 같이 알아볼까요?
1. 유니언 타입 (Union Types)
// ok
type StringOrNumber = string | number;
// error
interface StringOrNumber = string | number;
interface
로는 위와 같이 유니언 타입
을 정의할 수 없습니다.
interface
는 객체의 형태를 정의하는 데 특화되어 있기 때문에, 이러한 타입 조합은 불가능합니다.
2. 튜플 타입 (Tuple Types)
// ok
type StringNumberPair = [string, number];
// error
interface StringNumberPair = [string, number];
// ok - 그러나 배열의 구조를 정의할 뿐
interface StringNumberArray {
[index: number]: string | number;
}
interface는 배열의 구조를 정의할 수 있지만, 특정 길이와 특정 인덱스 타입을 가진 튜플은 정의할 수 없습니다.
3. 교차 타입 (Intersection Types)
// ok
type CombinedType = MyObject & {
additionalProperty: StringOrNumber
}
// error
interface CombinedType = MyObject & {
additionalProperty: StringOrNumber
}
// ok - 그러나 상속일 뿐 직접적인 교차는 아님
interface CombinedType extends MyObject {
additionalProperty: StringOrNumber
}
interface는 여러 인터페이스를 상속할 수 있지만, 이와 같은 직접적인 교차 타입은 사용할 수 없습니다.
4. 매핑된 타입 (Mapped Types)
매핑된 타입이란, TypeScript에서 기존의 객체 타입을 기반으로, 그 속성들을 변환하거나 조작하여 새로운 타입을 만드는 방법을 의미합니다. 반복적인 타입 정의를 줄이고, 코드의 유연성을 높일 수 있습니다.
// ok - 모든 속상을 읽기전용으로 변경
type Person = {
name: string;
age: number;
};
type ReadonlyPerson = {
readonly [K in keyof Person]: Person[K];
};
직접적인 매핑은 아니지만, type
으로 매핑된 타입을 정의한 뒤 interface
에서 이를 사용할 수는 있습니다.
// ok
interface Person {
name: string;
age: number;
}
// 매핑된 타입을 정의
type ReadonlyPersonType = Readonly<Person>;
// 매핑된 타입을 사용한 인터페이스 확장
interface ReadonlyPersonInterface extends ReadonlyPersonType {}
5. 조건부 타입 (Conditional Types)
// ok
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
위와 마찬가지로 type
으로 조건부 타입을 정의한 다음, interface
에서 이를 사용할 수는 있습니다.
// ok
type IsString<T> = T extends string ? 'Yes' : 'No';
// type으로 정의된 조건부 타입을 interface 속성값으로 부여
interface CheckType {
result: IsString<string>; // "Yes"
}
const example: CheckType = {
result: 'Yes', // 결과는 "Yes"가 되어야 합니다.
};
6. 정확한 리터럴 타입 조합
// ok
type SpecificValues = 'value1' | 'value2' | 42;
const example: SpecificValues = 'value1'; // 가능
예상 하셨듯이, 이 것도 type과 interface를 함께 쓰면서 사용할 수 있습니다.
// ok
type SpecificValues = 'value1' | 'value2' | 42;
interface ApiResponse {
value: SpecificValues;
message: string;
}
type과 interface의 best practice
그러면 어떤 상황에서 type
을 사용하고, 어떤 상황에서 interface
를 사용해야 할까요?
ChatGPT 4o
chatGPT는 이에 대한 가이드라인을 제시합니다.
type
을 사용하는 경우- 유니언 타입, 교차 타입 등
interface
에서는 불가한 타입 정의가 필요할 때 - 유틸리니 타입, 조건부 타입 등을 사용할 때
- 함수 타입 정의할 때 (특히, 함수의 반환 타입이 복잡할 때
interface
보다 더 직관적이고 간단함)
- 유니언 타입, 교차 타입 등
interface
를 사용하는 경우- 객체 구조를 정의할 때
- 확장 가능성이 있을 때 (라이브러리 설계, 다른 개발자가 타입을 확장해야하는 경우 등)
TypeScript team은 무엇을 권장할까?
2024년 기준으로 TypeScript 팀이 권장하는 사항은 다음과 같습니다:
가능한 경우 interface
를 사용할 것: interface
는 기본적으로 객체 구조를 정의하고 확장 가능하며,
여러 선언을 자동으로 병합할 수 있어 유연성과 확장성이 좋습니다.
객체의 형태를 정의하는 경우에는 interface
를 선호하는 것이 좋습니다.
특정한 경우에만 type
사용: 유니언 타입, 교차 타입, 혹은 함수나 복잡한 타입 조합이 필요한 경우에는
type을 사용하는 것이 좋습니다.
또한, type을 통해 구현된 타입은 interface보
다 유연하게 작동하므로, 타입 조작이 필요한 경우에 유리합니다.
속설 : type보다 interface가 더 빠르다?
잠정 결론 : 눈에 띄는 성능 차이는 없다.
이 얘기를 시작하기 전에 먼저 '빠르다' 는 것이 무엇을 의미하는지 알아야 합니다.
여기서 type
과 interface
의 '빠름'은 코드 런타임 성능이 아니라, IDE 내 TypeScript type checker
의 효율성(속도) 과 관련 있습니다.
"interface
가 더 빠르다"는 사람들의 근거는 다음과 같습니다.
interface
는 확장 가능성과 선언 병합 기능 덕분에 타입 검사기의 처리 속도에 약간의 이점을 가질 수 있습니다.- 반면에,
type
은 유니언 타입, 교차 타입, 조건부 타입 등 복잡한 타입 정의에 사용될 수 있으며, 이런 복잡한 타입 조합은 IDE의 타입 검사기에 더 많은 부담을 줄 수 있습니다.
이런 이유로 TypeScript Performance Wiki
(Performance)
에서는 interface
가 type
보다 빠르다는 글을 올렸던 때가 있었습니다(만, 현재는 해당내용이 없어졌습니다.)
하지만 이러한 성능 차이는 대부분의 경우 매우 미미하며, 실제 개발에서 눈에 띄는 차이를 느끼기 어렵습니다.
- 실제로 1000개의
type
과 1000개의interface
를 비교한 개발자 Anastasios Theodosiou는 두 개의 차이가 확실하지 않다고 얘기했습니다. (Types vs Interface) - 유튜버 Matt Pocock 역시 수 천개의 type과 interface를 비교했지만 별 차이 없다고 밝혔고요. (TypeScript: Should you use Types or Interfaces?)
이런 속설이 자꾸 퍼져나가고 사람들이 궁금해하자, TypeScript Team은 많은 논의를 하게 됐습니다. 그리고 결국
type과 interface 중 어떤 것을 쓰던, 그 것이 성능에 영향을 미치는 중요한 요소는 아니다.
라는 결론을 잠정 도출합니다. 여러분은 어떻게 생각하시나요?
IDE에서의 차이점 (VScode)
IDE에서 type
과 interface
를 사용할 때 느낄 수 있는 차이점들도 있습니다.
이를 통해 팀원들과 어떤 typescript 컨벤션을 쓸 지 논의하시면 될 것 같습니다.
1. 타입 미리보기 (hovering)
타입 위에 마우스를 올리면, 대부분의 IDE는 해당 타입의 구조를 전체적으로 미리 보여줍니다. vscode에서도 그렇죠. 예를 들어 다음과 같이 동일한 객체 타입을 가지는 UserType과 UserInterface를 봅시다.
// 타입
type UserType = {
name: string;
age: number;
role: 'admin' | 'user' | 'guest';
};
const user1: UserType = {
name: 'Hwonda',
age: 30,
role: 'admin',
};
// 인터페이스
interface UserInterface {
name: string;
age: number;
role: 'admin' | 'user' | 'guest';
}
const user2: UserInterface = {
name: 'Hwonda',
age: 30,
role: 'admin',
};
-
UserType
에 마우스를 올리면, 해당 타입의 구조를 미리 볼 수 있습니다. -
UserInterface
에 마우스를 올리면, 그냥interface UserInterface
라고만 나옵니다.
2. 타입 병합 (교차 타입 혹은 확장)
타입 추적은 IDE가 코드를 분석하여 변수의 타입을 추적하는 기능입니다.
vscode에서는 type
과 interface
의 타입 추적이 다르게 동작합니다.
// 타입
type AdminType = {
permissions: string[];
};
type UserType = {
name: string;
age: number;
};
type AdminUserType = AdminType & UserType;
const adminUser1: AdminUserType = {
name: 'Hwonda',
age: 30,
permissions: ['read', 'write'],
};
// 인터페이스
interface AdminInterface {
permissions: string[];
}
interface UserInterface {
name: string;
age: number;
}
interface AdminUserInterface extends AdminInterface, UserInterface {}
const adminUser2: AdminUserInterface = {
name: 'Bob',
age: 35,
permissions: ['read', 'write'],
};
-
AdminUserType
에 마우스를 올리면,AdminType
과UserType
이 병합된 것을 볼 수 있습니다. -
AdminUserInterface
에 마우스를 올리면,AdminUserInterface
만 나옵니다. (병합된 타입을 볼 수 없음)
3. 타입 병합 심화
위 예시처럼 두 개 정도의 타입을 병합할 때는 type
과 interface
의 차이가 크지 않습니다.
하지만 외부 라이브러리의 타입을 확장하거나, 여러 개의 ts 파일 내에서 확장을 했을 때 오류 추적은 어떨까요?
// 인터페이스 Person 정의
interface Person {
name: string;
age: number;
}
// 인터페이스 Employee 정의(Person을 확장)
interface Employee extends Person {
employeeId: number;
}
// 인터페이스 Person을 다시 정의(선언 병합)
interface Person {
department: string;
role: string;
}
// 인터페이스 Manager 정의(Person을 확장)
interface Manager extends Person {
subordinates: Employee[];
}
const manager: Manager = {
name: 'Bob',
age: 40,
department: 'Sales',
subordinates: [
{ name: 'Hwonda', age: 30, employeeId: 1234, department: 'HR' }, // error
],
role: 'Team Lead',
};
여기, interface 확장을 3번 정도 거쳐 만들어진 Manager라는 인터페이스가 있습니다. 그런데 한 가지 오류가 있군요.
뭔가 role에 대한 문제가 있어 보이죠? 하지만 error 메세지만 봐서는 잘 모르겠습니다. 일단 ChatGPT 형한테 물어봅니다.
이런… 제네릭<T>
이나 Omit<T,K>
유틸리티를 쓰라고 하는데… 뭔지 잘 모르겠습니다. 그냥 추적해볼까 싶습니다. 물론 subordinates에 들어가는 객체에 role을 넣으면 되지만 그건 의도한 동작은 아닙니다.
const manager: Manager = {
name: 'Bob',
age: 40,
department: 'Sales',
subordinates: [
{
name: 'Hwonda',
age: 30,
employeeId: 1234,
department: 'HR',
role: '넣고싶지 않아!!',
},
],
role: 'Team Lead',
};
다시 타입을 살펴 봅시다.
거슬러 올라가 살펴보니 Person
에 대한 선언 병합할 때 role
이 껴 들어갔군요?
물론 Manager에는 role
속성이 필요합니다만, Employee
에는 필요 없습니다. 일단 지워봅시다.
interface Person {
department: string;
// role 을 지웁니다.
}
그럼 다시 에러가 뜹니다.
아! 마참내..! 찾았습니다. Manager
타입에 있어야 할 role이 Person
에 있어서 에러가 났군요.
그럼 최종 코드는 다음과 같습니다.
// 인터페이스 Person 정의
interface Person {
name: string;
age: number;
}
// 인터페이스 Employee 정의(Person을 확장)
interface Employee extends Person {
employeeId: number;
}
// 인터페이스 Person을 다시 정의(선언 병합)
interface Person {
department: string;
// role 을 지웁니다.
}
// 인터페이스 Manager 정의(Person을 확장)
interface Manager extends Person {
subordinates: Employee[];
role: string; // Manager에 role을 추가합니다.
}
const manager: Manager = {
name: 'Bob',
age: 40,
department: 'Sales',
subordinates: [
{ name: 'Hwonda', age: 30, employeeId: 1234, department: 'HR' },
],
role: 'Team Lead',
};
type
으로 교차한다면 좀 더 쉬울지도 모릅니다.
type Person = {
name: string;
age: number;
};
type Employee = Person & {
employeeId: number;
};
type PersonWithRole = Person & {
department: string;
role: string;
};
type Manager = PersonWithRole & {
subordinates: Employee[];
};
const manager: Manager = {
name: 'Bob',
age: 40,
department: 'Sales',
subordinates: [
{ name: 'Hwonda', age: 30, employeeId: 1234, department: 'HR' },
],
role: 'Team Lead',
};
같은 이름의 타입을 사용할 수 없으니(선언 병합 불가), 애초부터 헷갈릴 일은 없고, 해당 error 메세지에 충실하게 Employee 형식에 누락된
department
값만 넣으면 해결됩니다.
type Employee = Person & {
employeeId: number;
department: string;
};
마치며
우아한 형제들에서는 type
을 사용합니다.
- 함수의 type을 정의할 때,
type
이 더 직관적이고 간단하다고 판단 - IDE에서 미리보기를 더 잘 지원함
- 원치 않는 선언 병합을 막음
한편 interface
는 다음과 같은 이유로 사용합니다.
- 외부 라이브러리를 사용할 때 선언 병합이 훨씬 편하다.
- 특히 대규모 프로젝트에서 여러 모듈에 동일한 인터페이스를 추가로 정의하는 경우가 많은데, 이때
interface
가 더 편리하다.
여러분의 팀에서는 어떤 컨벤션을 사용하시나요?