type과 interface 는 유사한 점이 매우 많다.
type과 interface는 객체의 타입의 이름을 지정하는 것인데 자유롭게 혼용되어 사용 가능하지만 둘 사이에는 차이와 한계가 분명히 존재한다.
원시 타입(Primitive Types)
{
type CustomString = string;
const str: CustomString = '';
// ❌
interface CustomStringByInterface = string;
}
타입은 원시 타입(symbol, boolean, string, number, bigint, etc.)을 정의할 수 있다. 반면에 인터페이스는 불가능하다. 타입은 새로운 타입을 만드는 것이 아니기 때문에 type alias로 불린다. 반면에 인터페이스는 항상 새로운 타입을 생성한다.
유니온 타입(Union Types)
{
type Fruit = 'apple' | 'lemon';
type Vegetable = 'potato' | 'tomato';
// 'apple' | 'lemon' | 'potato' | 'tomato'
type Food = Fruit | Vegetable;
const apple: Food = 'apple';
}
유니온 타입은 타입만 사용 가능하다.
튜플 타입(Tuple Types)
{
type Animal = [name: string, age: number];
const cat: Animal = ['', 1];
}
튜플 타입은 타입으로만 정의 가능하다.
객체/함수 타입(Objects / Function Types)
인터페이스와 타입 모두 객체 타입이나 함수 타입을 선언할 수 있다. 하지만 인터페이스의 경우, 같은 인터페이스를 여러번 선언 가능하다. 그리고 그들은 자동으로 병합된다. 반면에 타입은 자동으로 병합되지 않고 유니크 해야 하며, &연산자를 통해 병합 가능하다.
{
// 인터페이스를 사용할 때, 같은 이름의 인터페이스는 자동 병합된다.
interface PrintName {
(name: string): void;
}
interface PrintName {
(name: number): void;
}
// ✅
const printName: PrintName = (name: number | string) => {
console.log('name: ', name);
};
}
{
// 타입을 사용할 때, 그것은 유일 해야하고, 오직 &를 사용해야만 병합 가능하다.
type PrintName = ((name: string) => void) & ((name: number) => void);
// ✅
const printName: PrintName = (name: number | string) => {
console.log('name: ', name);
};
}
다른 키 포인트는 타입은 &(intersection)을 사용하고, 인터페이스는 상속(inheritance)를 사용한다.
{
interface Parent {
printName: (name: number) => void;
}
// ❌ 인터페이스 'Child'는 인터페이스 'Parent'를 잘못 확장했다.
interface Child extends Parent {
printName: (name: string) => void;
}
}
{
type Parent = {
printName: (name: number) => void;
};
type Child = Parent & {
// 여기서 두 printName은 intersection 된다.
// 이것은 `(name: number | string) => void`과 같다.
printName: (name: string) => void;
};
const test: Child = {
printName: (name: number | string) => {
console.log('name: ', name);
},
};
test.printName(1);
test.printName('1');
}
위에 나타난 에러와 같이 인터페이스를 상속할 때, 서브타입은 슈퍼타입과 충돌할 수 없고, 오직 확장만 가능하다.
{
interface Parent {
printName: (name: number) => void;
}
interface Child extends Parent {
// ✅
printName: (name: string | number) => void;
}
}
위에서 볼 수 있듯이 인터페이스는 extends를 사용하여 상속을 구현한다. 그리고 타입은 &를 사용하여 교차(intersection)을 구현한다.
매핑된 객체 타입(Mapped Object Types)
type Vegetable = 'potato' | 'tomato';
{
type VegetableOption = {
[Property in Vegetable]: boolean;
};
const option: VegetableOption = {
potato: true,
tomato: false,
};
// "potato" | "tomato"
type VegetableAlias = keyof VegetableOption;
}
{
interface VegetableOption {
// ❌ 매핑된 타입은 프로퍼티나 메서드로 선언할 수 없다.
[Property in Vegetable]: boolean;
}
}
export {};
매핑된 객체 타입은 타입으로만 정의될 수 있고, in 키워드와 keyof 키워드를 사용할 수 있다.
알려지지 않은 타입(Unknown Types)
{
const potato = { name: 'potato', weight: 1 };
// type Vegetable = {
// name: string;
// weight: number;
// }
type Vegetable = typeof potato;
const tomato: Vegetable = {
name: 'tomato',
weight: 2,
};
}
unknown 타입을 다룰 때, typeof를 사용하여 타입을 확인할 수 있다. 그러나 그것은 타입으로만 가능하고, 인터페이스는 불가하다.
인터페이스 선언 병합(Interface Declaration Merging)
동일한 이름의 인터페이스가 두 개 있다면 타입스크립트 컴파일러는 두 인터페이스의 속성을 양쪽이 공유하는 이름의 단일 인터페이스로 병합하려고 한다.
인터페이스 선언 병합의 예
코드 예제를 사용하여 발생할 수 있는 모든 병합 케이스에 대해 설명한다.
interface Car {
model: string;
engineSize: number;
}
interface Car {
manufacturer: string;
}
interface Car {
color: string;
}
const car: Car = {
color: 'red',
engineSize: 1968,
manufacturer: 'BMW',
model: 'M4',
numSeats: 4, // `numSeats` property is not specified
};
선언된 모든 인터페이스는 동일한 이름을 공유하기 때문에 이러한 인터페이스는 모두 집합 속성을 공유하면서 고유의 이름으로 병합된다. 또, 동일한 명칭을 가지는 모든 속성은, 동일한 종류여야 한다. 그 외의 경우는 다음 예시와 같이 컴파일 오류가 발생한다.
interface Car {
model: string;
engineSize: number;
}
interface Car {
manufacturer: string;
}
interface Car {
color: string;
numSeats: string;
}
interface Car {
numSeats: number; // Error: Subsequent property declarations must have the same type. Property 'numSeats' must be of type 'string', but here has type 'number'.ts(2717)
}
const car: Car = {
color: 'red',
engineSize: 1968,
manufacturer: 'BMW',
model: 'M4',
numSeats: 4,
};
그런 다음 함수 유형과 동일한 속성 이름을 가진 인터페이스를 조사한다.
interface Car {
model: string;
engineSize: number;
manufacturer: string;
color: string;
}
interface Car {
start(param: string);
}
interface Car {
start(param: number);
}
const bmw: Car = {
model: 'M4',
manufacturer: 'BMW',
engineSize: 2993,
color: 'white',
start: (param) => param,
};
bmw.start(2) // `start(param: number)` will be used
bmw.start('3') // `start(param: string)` will be used
동일한 구조를 갖는 기능을 포함하는 인터페이스가 병합되면 마지막으로 선언된 인터페이스의 기능이 병합된 인터페이스의 상단에 표시되며, 첫 번째 인터페이스에서 선언된 기능은 다음과 같이 표시된다.
interface Car {
start(param: string);
}
interface Car {
start(param: any);
}
interface Car {
start(param: number);
start(param: boolean);
}
// This is how the final merged interface looks like
interface Car {
// functions in the last interface appear at the top
start(param: number);
start(param: boolean);
// function in the middle interface appears next
start(param: any): number;
// function in the first interface appears last
start(param: string): string;
}
그 이유는 나중에 선언된 인터페이스가 처음 선언된 인터페이스보다 우선순위가 높기 때문이다. 위의 예를 고려하면 최종 병합된 Car 인터페이스에서는 start(param:any):number 메서드 선언이 start(param:string) 앞에 오기 때문에 start(param:string):string 메서드 선언은 임의의 유형을 나타낼 수 있으므로 start(param:string)는 인수로 전달되어도 호출되지 않는다. 이것은 같은 이름의 함수 파라미터가 문자열 리터럴을 유형으로 가지는 경우를 제외하고 모든 인터페이스에 적용된다. 위와 같은 순서를 따르지만 문자열 리터럴 타입의 함수가 우선이기 때문에 Declaration Merging에 의해 상위에 표시된다. 위에서 설명한 바와 같이 나중 인터페이스의 문자열 리터럴은 첫 번째 인터페이스 앞에 표시된다.
결론
타입은 인터페이스의 거의 모든 기능을 가지고 있다. 인터페이스는 확장 가능하고, 타입은 그렇지 않다.
타입을 사용해야 하는 경우
- 원시 타입을 정의할 경우
- 튜플 타입을 정의할 경우
- 함수 타입을 정의할 경우
- 유니온 타입을 정의할 경우
- 매핑된 타입을 정의할 경우
인터페이스를 사용해야 하는 경우
- 선언 병합(자동 병합)의 이점을 활용해야 하는 경우
- 객체 타입을 정의하거나, 타입을 사용할 필요가 없을 경우