본문 바로가기
Typescript

[TS] Type vs Interface

by SeanK 2023. 11. 24.

타입과 타입변수

type은 타입스크립트에서 데이터의 모양을 규정하는데 쓰는 키워드입니다. 타입스크립트에서 기본 타입으로는 아래와 같습니다:

  • String
  • Boolean
  • Number
  • Array
  • Tuple
  • Enum
  • Advanced types

각각의 타입은 고유의 역할과 기능이 있고 개발자는 목적에 알맞게 사용하게 됩니다.

 

타입스크립트에서 타입변수는 "어떤 타입의 이름"을 의미합니다. 기존에 존재하는 타입을 위한 새로운 타입 이름을 만들어 낼 수 있도록 하죠. 타입변수가 새로운 타입을 정의하는 것은 아닙니다. 대신에 기존에 존재하던 타입 이름에 별도의 이름을 제공합니다. 타입변수는 원시 타입을 포함해 유효한 타입스크립트 타입을 type 키워드를 통해 생성할 수 있습니다.

 

type MyNumber = number;
type User = {
  id: number;
  name: string;
  email: string;
}

 

위 예제에서, MyNumber와 User 두 개의 타입변수를 만들었습니다. 이후 MyNumber는 number 타입을 대신해 사용할 수 있고 User 타입변수는 유저 데이터의 타입을 정의합니다.

 

보통 types vs interfaces라고 이야기 할때는 타입변수와 인터페이스를 의미하는 것입니다. 예를 들어 아래와 같이 변수를 만들었다고 가정해 보겠습니다.

type ErrorCode = string | number;
type Answer = string | number;

 

두 개의 타입변수들은 같은 유니온 타입을 가지지만 서로 다른 변수명을 가지고 있습니다. 타입은 같더라도 서로 다른 의도를 가진 이름을 가지고 있기 때문에 코드의 가독성이 높아집니다.

 

타입스크립트의 인터페이스

타입스크립트에는, 어떤 객체가 무조건적으로 따라야 할 약속이 정해져 있습니다. 아래와 같이 말이죠.

interface Client { 
    name: string; 
    address: string;
}

 

동일한 Client 약속을 type을 통해서도 똑같이 표현할 수 있습니다.

type Client = {
    name: string;
    address: string;
};

 

타입과 인터페이스의 차이점

위의 예제의 경우 타입과 인터페이스 모두 사용가능했었습니다. 하지만 경우에 따라선 인터페이스 대신 타입을 사용해야 하는 차이점이 발생하기도 합니다.

원시 타입

원시타입은 타입스크립트에 내장된 타입을 뜻합니다. 원시타입으로는 number, string, boolean, null, 그리고 undefined 타입이 있습니다.

이러한 원시 타입을 이용해 아래와 같이 타입변수를 정의할 수 있습니다.

type Address = string;

 

종종  타입변수를 정의 할 때 코드의 가독성을 높이기 위해 원시타입을 유니온 타입으로 합치기도 합니다.

type NullOrUndefined = null | undefined;

 

하지만 원시 타입에 변수명을 붙일 때 인터페이스를 사용하는 것은 불가능합니다. 인터페이스는 오로지 객체 타입만을 위해 이용할 수 있기 때문입니다. 따라서 원시 타입을 정의하기 위해서는 타입을 이용해야 합니다.

유니온 타입

유니온 타입은 어떤 값이 가질 수 있는 여러 가지 데이터 형식을 설명하고 여러 원시형, 리터럴, 혹은 복잡한 타입의 그룹을 만들어 줍니다.

type Transport = 'Bus' | 'Car' | 'Bike' | 'Walk';

 

유니온 타입은 타입을 이용해서만 정의될 수 있습니다. 인터페이스에는 유니온 타입과 같이 타입을 합칠 수가 없습니다. 하지만 두 개의 인터페이스를 이용한 하나의 유니온 타입을 생성하는 것은 가능합니다.

interface CarBattery {
  power: number;
}
interface Engine {
  type: string;
}
type HybridCar = Engine | CarBattery;

함수 타입

타입스크립트에서 함수 타입은 함수의 타입 시그니처를 나타냅니다. 타입변수를 이용해, 파라미터와 리턴값의 타입을 정의합니다.

type AddFn =  (num1: number, num2:number) => number;

 

함수 타입을 나타내기 위해 인터페이스를 사용할 수 있습니다.

interface IAdd {
   (num1: number, num2:number): number;
}

 

위처럼 타입과 인터페이스는 함수 타입을 비슷하게 정의하나 미세한 문법차이가 있습니다. 타입은 "=>"기호를 썻지만 인터페이스는 ":" 기호를 사용하였죠. 이 경우 읽기가 쉽고 짧은 타입이 선호됩니다.

 

함수 타입을 정의할 때 인터페이스보다 타입을 사용하는 이유는 인터페이스의 기능제한 때문입니다. 함수가 더 복잡해지면, 타입의 여러 고급 기능을 활용해 조건부 타입, 매핑 타입 등과 같은 기능을 활용할 수 있습니다.

type Car = 'ICE' | 'EV';
type ChargeEV = (kws: number)=> void;
type FillPetrol = (type: string, liters: number) => void;
type RefillHandler<A extends Car> = A extends 'ICE' ? FillPetrol : A extends 'EV' ? ChargeEV : never;
const chargeTesla: RefillHandler<'EV'> = (power) => {
    // Implementation for charging electric cars (EV)
};
const refillToyota: RefillHandler<'ICE'> = (fuelType, amount) => {
    // Implementation for refilling internal combustion engine cars (ICE)
};

 

위 코드에서 RefillHandler 타입을 조건부 와 유니온 타입으로 정의했습니다. 이렇게 하면 타입-세이프 방식으로 EV와 ICE 핸들러에 통합된 함수 시그니처를 제공할 수 있습니다. 하지만 인터페이스로는 조건부와 유니온 타입과 같은 기능이 없기 때문에 동일한 코드를 만들 수 없습니다.

Declaration merging

Declaration merging은 인터페이스에만 있는 기능입니다. Declaration merging을 이용하면, 하나의 인터페이스를 여러 번 선언할 수 있습니다. 그러면 타입스크립트 컴파일러가 자동으로 하나의 인터페이스로 정의하게 됩니다.

 

아래 예를 보시면 Client 인터페이스 정의가 타입스크립트 컴파일러에 의해 하나의 정의로 합쳐지게 되고, 결과적으로 두개의 프로퍼티를 가지게 됩니다.

interface Client { 
    name: string; 
}

interface Client {
    age: number;
}

const harry: Client = {
    name: 'Harry',
    age: 41
}

 

반면에 타입 변수의 경우 같은 방식으로 합쳐질 수 없습니다. 만약에 Client 타입을 한 번 이상 선언하려고 한다면 아래와 같은 에러문을 만나게 될 겁니다.

 

적절한 장소에서 활용한다면 declaration merge는 매우 유용할 수 있습니다. 가장 흔히 사용되는 경우는 특정한 프로젝트에서 필요로 하는 서드파티 라이브러리의 타임 정의를 확장해야 할 경우입니다.

 

만약 declaration merge가 필요하다면 인터페이스를 사용하는 것이 맞습니다.

Extends vs intersection

하나의 인터페이스는 하나 혹은 다수의 인터페이스를 상속할 수 있습니다. extends 키워드를 사용해 새 인터페이스는 기존의 인터페이스로부터 모든 프로퍼티와 메서드를 상속받고 더 나아가 추가할 수도 있습니다.

 

예를 들어, VIPClient 인터페이스를 Client 인터페이스를 상속해 생성할 수 있습니다.

interface VIPClient extends Client {
    benefits: string[]
}

 

타입에서는 intersection 오퍼레이터를 이용해 상속할 수 있습니다.

type VIPClient = Client & {benefits: string[]}; // Client is a type

 

정적으로 확정된 타입 변수 또한 상속할 수 있습니다.

type Client = {
    name: string;
};

interface VIPClient extends Client {
    benefits: string[]
}

 

예외적인 상황은 타입이 유니온 타입일 경우입니다. 만약 유니온 타입으로부터 인터페이스를 확장하려고 한다면 다음과 같은 에러를 만나게 될 겁니다.

type Jobs = 'salary worker' | 'retired';

interface MoreJobs extends Jobs {
  description: string;
}

 

 

위와 같은 에러코드가 발생하는 이유는 유니온 타입은 정적으로 확정되지 않았기 때문입니다. 인터페이스 정의는 컴파일 타임에 정적으로 확정되어 있어야 합니다.

 

타입 변수는 intersection을 이용해 아래와 같이 확장할 수 있습니다.

interface Client {
    name: string;
}
Type VIPClient = Client & { benefits: string[]};

 

결론적으로 인터페이스와 타입 모두 상속이 가능합니다. 인터페이스는 정적으로 확정된 타입변수를 상속받을 수 있고, 타입변수는 intersection 오퍼레이터를 이용해 인터페이스를 상속받을 수 있습니다.

상속할 때의 에러 핸들링

타입과 인터페이스의 또 다른 차이점은 같은 프로퍼티 이름을 가진 타입 혹은 인터페이스로 상속할 때의 충돌을 어떻게 처리하는지 입니다.

 

인터페이스를 상속할 때 같은 프로퍼티가 있으면 에러가 발생합니다.

interface Person {
  getPermission: () => string;
}

interface Staff extends Person {
   getPermission: () => string[];
}

 

위 코드는 아래 에러를 발생시킵니다.

 

타입 변수는 충돌을 다르게 처리합니다. 만약에 상속받는 타입이 상속하는 타입과 같은 프로퍼티 이름을 가진다면 자도으로 모든 프로퍼티를 합쳐버리고 에러를 발생시키지 않습니다.

 

아래 예제에서, intersection 오퍼레이터는 두개의 getPermission 선언 메서드 시그니처를 합치고 있습니다. 그리고 typeof 오퍼레이터가 유니온 타입의 파라미터를 상세히 구분해 type-safe 하게 리턴값을 반환하고 있습니다.

type Person = {
  getPermission: (id: string) => string;
};

type Staff = Person & {
   getPermission: (id: string[]) => string[];
};

const AdminStaff: Staff = {
  getPermission: (id: string | string[]) =>{
    return (typeof id === 'string'?  'admin' : ['admin']) as string[] & string;
  }
}

 

하지만 동일한 프로퍼티를 상속하면서 merge된 타입은 예측하지 못한 결과를 만들어 낼 때도 있습니다. 아래 예문을 보면, Staff 타입의 name 프로퍼티는 never의 타입을 가지게 됩니다. 왜냐하면 stirng과 number 타입이 동시에 될 수 없기 때문입니다.

type Person = {
    name: string
};

type Staff = person & {
    name: number
};
// error: Type 'string' is not assignable to type 'never'.(2322)
const Harry: Staff = { name: 'Harry' };

 

요약하자면, 인터페이스는 프로퍼티와 메소드 이름을 추적해 충돌이 발생하면 컴파일 시 에러를 발생시킵니다. 반면에 타입의 intersection은 프로퍼티와 메서드를 합쳐버리고 에러를 발생시키지 않습니다. 따라서 만약 함수를 오버로드 해야 할 경우라면 타입변수를 이용하는 게 좋습니다.

인터페이스와 타입변수를 이용한 클래스 활용

타입스크립트에서 클래스를 이용할 때 인터페이스와 타입변수 모두 이용할 수 있습니다.

interface Person {
  name: string;
  greet(): void;
}

class Student implements Person {
  name: string;
  greet() {
    console.log('hello');
  }
}

type Pet = {
  name: string;
  run(): void;
};

class Cat implements Pet {
  name: string;
  run() {
    console.log('run');
  }
}

 

위에서 나타나듯이 인터페이스와 타입 변수는 모두 클래스에서 비슷하게 이용할 수 있습니다. 한가지 차이점이라면 유니온 타입을 이용할 수 없다는 점입니다.

type primaryKey = { key: number; } | { key: string; };

// can not implement a union type
class RealKey implements primaryKey {
  key = 1
}

 

 

위의 예제에서 클래스는 한가지 특정한 데이터 형태를 가져야 하지만 유니온 타입의 경우 여러 데이터 타입을 가질 수 있기 때문에 컴파일러가 에러를 발생시킨 것을 확인할 수 있습니다.

튜플 타입

타입스크립트에서 튜블 타입은 고정된 개수의 엘리먼트를 표현할 수 있도록 해줍니다. 고정된 구조의 배열 데이터를 작업할 때 유용합니다.

type TeamMember = [name: string, role: string, age: number];

 

인터페이스의 경우 튜블 타입을 직접적으로 지원하지 않습니다. 다먄 아래 예제처럼 우회적으로 표현할 수는 있으나 간결하지 않고 가독성이 떨어지죠.

interface ITeamMember extends Array<string | number> 
{
 0: string; 1: string; 2: number 
}

const peter: ITeamMember = ['Harry', 'Dev', 24];
const Tom: ITeamMember = ['Tom', 30, 'Manager']; //Error: Type 'number' is not assignable to type 'string'.

고급 타입 기능

타입스크립트는 인터페이스에서는 사용할 수 없는 다양한 고급 타입 기능을 제공합니다. 타입스크립트의 몇몇 고급 기능들로는 아래와 같습니다.

  • 타입추론: 사용에 따라 편수와 함수의 타입을 추론합니다. 이는 코드량을 줄이고 가독성을 높입니다.
  • 조건부 타입: 조건에 따른 행동으로 복잡한 타입표현이 가능합니다.
  • 타입보호: 변수의 타입에 따른 세련된 컨트롤을 위해 사용합니다.
  • 매핑 타입: 기존에 존재하는 객체 타입을 새로운 타입으로 변환합니다.
  • 유틸리티 타입: 타입을 다루는데 유용한 유틸리티들을 제공합니다.

매 업그레이드마다 타입스크립트는 복잡하고 강력한 툴박스로 타입핑 시스템을 발전시켜오고 있습니다. 이러한 타이핑 시스템이 바로 개발자들이 타입스크립트를 선호하는 주요 이유 중의 하나입니다.

 

언제 타입을 사용하고 언제 인터페이스를 사용하는가

이전 섹션에서 살펴봤듯이, 타입변수와 인터페이스는 비슷하지만 미묘한 차이점이 있습니다.

 

인터페이스의 대부분의 기능을 타입변수가 가지고 있거나 비슷한 표현을 가지고 있지만 declaration merging만이 그렇지 않았습니다. 인터페이스는 기존의 라이브러리를 확장하거나 새로운 것으로 다시 작성할 때와 같은 경우에 일반적으로 사용됩니다. 추가로 만약에 객체-지향 상속 스타일을 좋아한다면 인터페이스의 extends 키워드가 가독성이 더 좋을 수 있습니다.

 

하지만 타입변수의 많은 기능들이 인터페이스에겐 구현하기 어렵고 구현할 수 없는 기능들도 많습니다. 예를 들어, 타입스크립트는 조건부 타입, 제너릭 타입, 타입 보호, 고급 타입 등등과 같은 다양한 기능들을 제공합니다. 이러한 기능을 이용해 어플리테이션을 강력하게 타입 된 시스템으로 구축할 수 있습니다. 인터페이스로는 구현할 수가 없죠.

 

많은 경우에 이 둘은 상호교환이 가능하기 때문에 개인적 선호에 따라 사용하면 되지만 아래의 경우에는 타입변수를 사용해야 합니다.

  • 원시 타입의 새로운 이름을 만들려고 할 때
  • 유니온 타입, 튜블 타입, 함수 타입, 그리고 복잡한 타입을 만들려고 할 때
  • 함수를 오버로드 할 때
  • 매핑 타입, 조건부 타입, 타입 보호, 혹은 고급 타입 기능을 이용하고자 할 때

인터페이스와 비교했을 때 타입은 표현력이 더 강력합니다. 타입의 고급 기능들은 인터페이스에서는 사용이 불가능한데, 타입스크립트가 진화함에 따라 타입의 고급 기능들은 더욱 강력해질 것입니다.

아래은 인터페이스로는 만들 수 없는 타입의 고급기능들입니다.

type Client = {
    name: string;
    address: string;
}
type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]:  () => T[K];
};
type clientType = Getters<Client>;
// type clientType = {
//     getName: () => string;
//     getAddress: () => string;
// }

 

매핑 타입과 탬플릿 리터럴 타입 그리고 keyof 오퍼레이터를 이용해 자동적으로 어떤 객체 타입이든 getter 메서드를 만들어내는 타입을 만들었습니다.

 

추가로, 많은 개발자들이 함수적 프로그래밍 패러다임과 잘 맞기 때문에 파입의 사용을 선호합니다. 풍부한 타입 표현은 함수적 구성, 불편성 그리고 다른 함수적 프로그래밍 요소를 type-safe 방식으로 구현할 수 있도록 해줍니다.

 

 

 

 

 

 

 

출처: https://blog.logrocket.com/types-vs-interfaces-typescript/

 

Types vs. interfaces in TypeScript - LogRocket Blog

It can be difficult to choose between types and interfaces in TypeScript, but in this post, you'll learn which to use in specific use cases.

blog.logrocket.com