Interface vs Type Alias

2024년 6월 24일 (5달 전)

현대 웹 개발을 하다 보면 TypeScript로 작성된 코드를 많이 접하게 되는데, TypeScript란 컴파일 타임에 정적 타입 체크를 해주는 JavaScript의 상위 집합(superset) 언어입니다.

타입스크립트와 자바스크립트 포함관계두 언어의 포함관계

JavaScript에서 변수를 만들어 사용하듯, TypeScript에서도 내가 사용할 타입을 만들 수 있고 이 타입을 준수하도록 명시하여 에러를 조기에 발견하거나 자동완성을 더 잘 활용할 수 있습니다. 타입을 만드는 방식에는 interface 키워드를 이용한 interfacetype 키워드를 이용한 type alias가 있습니다. 이번 글에서는 각 키워드의 문법과 객체 타입에 한하여 프로퍼티 추가 방법에 대해 알아보겠습니다.

기본 문법

Interface

interface는 객체 타입(object type)에 대한 선언으로 크게 변수, 클래스, 함수의 타입 선언에 이용될 수 있지만 이 글에선 변수의 타입을 체크하는 것을 중점적으로 알아보겠습니다. 예시는 string 타입의 name 프로퍼티와 number 타입의 age 프로퍼티를 가진 객체의 국룰 집합 Person입니다.

interface Person {
  name: string;
  age: number;
}

// 선언된 변수의 타입 체크
const me: Person = { name: 'chogyejin', age: 20 }; // ✅
const me2: Person = { name: 'chogyejin' }; // ❌, 'age' 프로퍼티가 없음

// 함수 매개변수의 타입 체크
function printName(student: Person) {
  console.log(student.name);
}
printName(me); // ✅
printName({ name: 'chogyejin', age: 20 }); // ✅
printName({ name: 'chogyejin' }); // ❌, 'age' 프로퍼티가 없음

한때 interface로 선언된 타입임을 명시적으로 표현하기 위해 I로 시작하는 네이밍인 헝가리안 표기법을 사용하기도 했었습니다(IProps, IPerson 등). 하지만 최근엔 IDE의 발전 및 가독성 저하를 이유로 지양하는 추세이며, TypeScript는 아니지만 Microsoft의 .NET 네이밍 컨벤션 문서에서도 사용하지 말도록 규정하고 있습니다.

Prettify라는 유틸리티 타입을 활용해 커서를 올렸을 때 새로운 타입의 프로퍼티들을 더 쉽게 확인할 수도 있습니다!

Type Alias

type alias는 특정 타입에 대한 별칭을 지어주는 방식입니다. 객체 형태의 타입 선언에만 사용 가능한 interface와 달리, 어떠한 타입이든 들어올 수 있습니다. 또한 '별칭'이기 때문에 VSCode 기준 커서를 올렸을 때 interface보다 구체적으로 내부를 표기해주기도 합니다.

// 이런 식으로 기본 타입을 다시 명명할 수도 있습니다.
type Name = string;
type Age = number;

type Person = {
  name: Name;
  age: Age;
};

// 변수로 선언된 변수에 대해 체크
const me: Person = { name: 'chogyejin', age: 20 }; // ✅
const me2: Person = { age: 20 }; // ❌, 'name' 프로퍼티가 없음

// 함수의 매개변수에 대해 체크
function printName(student: Person) {
  console.log(student.name);
}
printName(me); // ✅
printName({ name: 'chogyejin', age: 20 }); // ✅
printName({ age: 20 }); // ❌, 'name' 프로퍼티가 없음

새로운 프로퍼티 추가하기

객체를 사용하는 대부분의 상황에선 위와 같이 interfacetype가 서로 대체 가능하지만, 새로운 프로퍼티 추가할 땐 미묘한 차이가 발생합니다. Person에서 string 타입의 school 프로퍼티가 추가된 Student를 만들어보겠습니다.

Interface: extends

interface Person {
  name: string;
  age: number;
}

interface Student extends Person {
  school: string;
}

extends는 type alias로 만들어진 타입을 포함하여 2개 이상의 타입을 확장시킬 수도 있습니다. 주의할 점은 extends 뒤에 오는 타입들에 동일한 프로퍼티가 있을 때 타입이 일치하지 않는다면 에러가 발생합니다.

interface Person {
  name: string;
  age: number;
}

type StringAgePerson = {
  name: string;
  age: string;
};

// ❌, 'Person'과 'StringAgePerson'의 'age' 프로퍼티가 동일하지 않음(not identical)
interface Student extends Person, StringAgePerson {
  school: string;
}

Interface: 선언 병합

interface Student {
  name: string;
  age: number;
}

interface Student {
  school: string;
}

선언 병합(Declaration Merging)은 동일한 이름의 2개 이상의 interface 선언(declaration)을 합쳐 하나의 정의로 병합(merging)시킵니다. 주의할 점은 extends와 유사하게 각 interface 선언에 있는 프로퍼티도 중복이 가능하지만, 앞서 선언된 프로퍼티의 타입과 일치해야 합니다.

interface Student {
  name: string;
  age: number;
}

interface Student {
  school: string;
}

// ✅, 동일한 타입의 school 중복 선언 가능
interface Student {
  school: string;
}

interface Student {
  school: 'string literal'; // ❌, 'school' 프로퍼티는 string 타입이어야 함
}

선언 병합은 전역 객체(Window)나 third-party 패키지의 타입에 대해 프로퍼티를 추가할 때 주로 사용됩니다. 서버 상태 관리 라이브러리의 대장인 Tanstack Query에서도 이를 활용하고 있습니다!

Type Alias: 교집합

type alias에선 &를 이용하여 타입을 교차(intersection)시켜 새로운 프로퍼티를 추가합니다. 개인적으로 처음 intersection을 접했을 땐 앞서 소개된 방식들보다 직관적이지 않다고 느꼈었는데, 이유는 TypeScript가 집합을 기반으로 동작하는 언어임을 받아들이지 못했었기 때문입니다.

다시 Person을 가져와 Student를 만들어보겠습니다.

type Person = {
  name: string;
  age: number;
};

type Student = Person & { school: string };
Person 타입과 Student 타입의 관계Student는 Person의 부분집합이기도 하다

Person은 name과 age를 갖는 객체 하나가 아닌 name과 age를 갖는 객체들의 집합으로 이해해야 하며, string 타입의 school 프로퍼티를 가진 객체들의 집합의 교집합이 Student가 됩니다.

이번엔 Person과 이름이 있는 컴퓨터 객체 집합을 교차시켜 인조인간 객체 타입 Android을 만들어보겠습니다.

type Person = {
  name: string;
  age: number;
};

type Computer = {
  name: string;
  cpu: string;
  ram: string;
};

type Android = Person & Computer;

이전의 예시와 다른 점은 name이라는 중복된 프로퍼티가 있습니다. interface에서의 확장 방법과 마찬가지로 각 프로퍼티가 같은 타입일 땐 문제가 되지 않지만, 만약 컴퓨터의 name이 string 타입이 아니라면 어떻게 될까요?

type Person = {
  name: string;
  age: number;
};

type Computer = {
  name: number; // 컴퓨터 이름이 숫자
  cpu: string;
  ram: string;
};

type Android = Person & Computer;
// {
//   name: ???;
//   age: number;
//   cpu: string;
//   ram: string;
// }
Person 타입과 Computer 타입의 교집합이름을 알 수 없는 Android

위처럼 동일한 이름을 가진 프로퍼티에 대해 다시 & 연산을 거치게 되는데,

  • string 타입은 모든 문자열의 집합
  • number 타입은 모든 숫자의 집합

으로 파생된 두 집합의 교집합은 원소가 없는 공집합이기 때문에 name은 never 타입이 되어 어떠한 값도 할당할 수 없게 됩니다. 따라서 intersection은 단순히 프로퍼티를 추가한다가 아닌 교집합을 표현한다는 것을 기억해야 합니다.

마치며

지금까지 타입을 만드는 방법으로의 interface와 type alias, 프로퍼티의 추가의 측면으로 키워드를 알아봤습니다. 과거 사내 컨벤션을 정립하고 개인적으로 작업할 땐 interface를 선호했는데 이유는 아래와 같습니다.

  • 일단 interface를 쓰다가 type이 필요할 때 쓰라고 되어 있음(공식 문서)
  • TypeScript GitHub Wiki에 따르면 성능적인 측면에서 interface를 추천
  • React에서 객체를 주로 다룸(Props)

하지만 최근엔 아래와 같은 이유로 interface를 잘 사용하지 않게 되었습니다.

interface와 type alias를 모두 써보면서 느낀 점은 개인 및 팀의 컨벤션에 따라 정하기 나름이고, 각 키워드가 필요한 경우가 생기기 때문에 상황에 맞춰 사용하면 된다는 것입니다. 그리고 이번 글에서 직접적으로 언급하진 않았지만 TypeScript의 Structural Type SystemFreshness도 중요한 개념이기에 함께 알아보시길 추천드리며 글을 마칩니다. 🙆‍♂️