본문 바로가기

javascript/typescript

[typescript] Type vs Interface

 면접에서 이 타입과 인터페이스를 사용한 이유에 대한 질문이 나왔다. 둘 사이의 차이를 깊게 생각해 본 적이 없어서, 이 부분을 이상하게 대답했던 점이 참 아쉽게 느껴진다. type은 몰라도 interface에 대해서는 선언 병합이라는 명확한 이점이 있는데, 이게 왜 문을 나온 후에 기억나는 걸까? 정말 너무 아쉽다.

 이러한 배경에서 type과 interface의 차이에 대해 정리해보기로 한다.

type alias

https://www.typescriptlang.org/ko/docs/handbook/2/everyday-types.html#%ED%83%80%EC%9E%85-%EB%B3%84%EC%B9%AD

 우리는 쉽게 type이라고 칭하지만, 공식 문서에서는 type alias라고 명시되어 있다. 타입 별칭은 객체 / 유니언 타입에 대해 직접 표현하는 대신 "별칭"을 붙이는 것이다. 객체를 포함한 모든 타입에 대해 이름을 부여할 수 있으며, 타입 별칭이 다르더라도 구성된 타입이 같다면 동일한 결과가 나온다.

type String_Alias = string; // 문자열에 대한 별칭

function work1(str: String_Alias) {
  console.log(str);
}

function work2(str: string) {
  console.log(str);
}

const str1: string = "str1";
const str2: String_Alias = "str2";

work1(str1);
work2(str2);

 string 타입에 대한 별칭으로 String_Alias을 만든 후, 각 타입을 인자로 받는 함수를 작성했다. string 타입의 문자열 str1은 String_Alias 타입을 받는 work1에서 잘 동작한다. 반대도 마찬가지이다.

출력된 결과. string / StringAlias 사용에 대한 에러 같은 것은 발생하지 않는다.
타입 명이 나타나는 모습.

 앞서 언급한 것처럼, 타입 별칭은 객체에도 사용할 수 있다.

function printUser(user: User) {
  console.log(user.id, user.name);
}

function printIdAndName(user: HasIdAndName) {
  console.log(user.id, user.name);
}

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

type HasId = {
  id: number;
}

type HasName = {
  name: string;
}

type HasIdAndName = HasId & HasName;

type NewType = { id: number } & { name: string };

const user1: User = {
  id: 1,
  name: 'hello'
};

const user2: HasIdAndName = {
  id: 2,
  name: 'world'
};

const user3: NewType = {
  id: 3,
  name: '이것도 된다고!'
};

printIdAndName(user1);
printUser(user2);
printIdAndName(user3);
printUser(user3);

출력된 결과

 타입 별칭에서 &을 이용하면 여러 타입을 결합할 수 있다. 위 결과를 보면 알 수 있듯이 User 타입, HasIdAndName 및 NewType은 처음에 정의한 방식은 다르지만 결과적으로 모두 같은 타입인 { id: number, name: string } 을 가리키므로 상호 간에 차이를 두지 않는다.

 참고로 동일한 프로퍼티를 가진 두 타입을 결합하면, 해당 프로퍼티는 never로 취급된다.

겹친 프로퍼티 id가 never로 간주되는 모습

interface

 객체에 대한 타입을 만드는 방법이다. type alias처럼 이름이 바뀌더라도 구조만 같으면 같은 타입으로 취급한다. 객체에 대한 타입을 만드는 방법이라는 점이 참 중요한데, 반대로 말하면 원시 타입에 대해서는 인터페이스 사용이 불가능하다.

interface IUser {
  id: number;
  name: string;
}

interface IHasId {
  id: number;
}

interface IHasName {
  name: string;
}

interface IHasIdAndName extends IHasId, IHasName {}

const user4: IUser = {
  id: 4,
  name: 'user4'
};

const user5: IHasIdAndName = {
  id: 5,
  name: 'user5'
};

printIdAndName(user4);
printUser(user5);

 위에서 선언한 printUser / printIdAndName을 이용하여 IUser / IHasIdAndName 유저를 출력했다. 당연하지만 기존 User 및 HasIdAndName과 타입 구조가 호환되므로 문제없이 값이 출력된다. (에러는 당연히 X)

출력된 결과

 다만 인터페이스는 원시 타입에 대한 유니온 같은 것은 표현할 수 없다. 애초에 인터페이스는 객체지향에서 추상화를 목적으로 등장한 개념이다. 객체에 대해 불필요한 구현 사항을 가려 꼭 필요한 동작 / 프로퍼티만 노출하는 것을 목적으로 등장한 키워드가 원시 타입인 string / number 등에 대해 적용되는 것이 더 이상한 것이다. 

구조적 타이핑 ( 덕 타이핑 )

 아직까지 타입스크립트는 자바스크립트의 super set 언어로, 실제로 동작하기 위해서는 자바스크립트로 변환된다. 이러한 측면에서 보면, 구조적으로 동일하다면 동일한 타입으로 취급하는 것이 자연스럽다. 함수에 전달되는 파라미터가 같은 구조를 가지고 있다면 자바스크립트 수준에서는 잘 실행되기 때문이다.

 타입 별칭 및 인터페이스는 타입스크립트의 근본적인 타입 시스템인 구조적 타입 시스템을 따른다. 따라서 구조만 같다면 타입으로 선언하든, 인터페이스로 선언하든, 타입을 결합하든지간에 상관 없이 모두 동일한 것으로 간주된다.

type alias vs interface

타입과 인터페이스는 둘 다 구조적 타이핑 기반으로 동작하며, 타입스크립트의 타입 환경을 정의할 때 사용될 수 있다. 얼핏 생각하면 타입은 원시 타입도 정의할 수 있는데 인터페이스는 객체 타입만 정의할 수 있으므로 type이 더 좋은거 아닌가? 하는 생각이 들 수도 있다. 결론만 말하자면 둘 다 서로 대체할 수 없는 영역이 있다.

type alias - union

 앞서 언급했듯이 인터페이스는 원시형을 묘사할 수 없다. 가끔 원시형에 대한 유니언을 선언하여 사용하는 것이 편한 경우가 있는데, 이런 상황에서는 type alias가 좋은 해답이 된다.

 나만의 ORM을 만드는 상황을 생각해보자. 사용자가 사용하고자 하는 DBMS의 이름을 db라는 프로퍼티를 통해 받아오며, 현재 지원되는 DBMS는 mysql, mongodb, mariadb뿐이다. 따라서 만약 사용자가 'sqlserver' 처럼 현재 지원하지 않는 DBMS을 db에 입력하려고 하면 에러를 발생시켜야 한다.

 위 상황을 해결할 수 있는 방법은 리터럴 타입에 대한 유니온을 지정하는 것이다. 'mysql', 'mongodb' 및 'mariadb'를 유니온한 후 별도의 타입 별칭을 붙여, 해당 리터럴 목록에 포함되지 않으면 에러로 간주한다.

type AvailableDBMS = 'mysql'|'mongodb'|'mariadb';

interface OrmOptions {
  db: AvailableDBMS;
  host?: string;
  user?: string;
  password?: string;
  // ... 등등
}

 type alias의 &(intersection)은 interface의 extends로 대신할 수 있지만, |(union) 의 결과로 나오는 타입은 인터페이스로 묘사할 수 없다. 이외에도 keyof, 

interface - declaration merging(선언 병합)

https://www.typescriptlang.org/ko/docs/handbook/declaration-merging.html#handbook-content

선언 병합은 컴파일러에 의해 여러 개의 정의를 하나로 합치는 것을 의미한다. 인터페이스는 선언 병합에 의해 선언된 내용이 하나로 합쳐질 수 있다. 반면, type alias의 경우 선언 병합이 적용되지 않는다.

type과 interface의 병합 차이

 일부 라이브러리에서는 선언 병합을 이용하여 사용자가 정의한 데이터 타입을 사용할 수 있게 한다. 보통 express 계열 미들웨어 쪽에서 이런 경우가 많이 있었던 것 같다.

// express request 확장

//1. import로 User 가져오는 방식
import { User } from "./src/models/user";

declare global {
    namespace Express {
        export interface Request {
            user: User;
        }
    }
}

//2. import 구문 사용
//https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-9.html#import-types
declare namespace Express {
    type User = import('../../model/user.js').UserEntity
    interface Request {
        user?: User
        // user?: User
    }
}

import 구문 부분은 좀 어렵다고 느껴서 관련된 설명이 있는 링크를 남긴다.

https://stackoverflow.com/questions/39040108/import-class-in-definition-file-d-ts


type alias와 interface 중 무엇을 사용할지는 사용자와 팀 마음이다. 공식 문서에서는 일단 모르겠다면 interface을 사용하고, 문제가 발생할 때 type을 사용하기를 권장하고 있다.