현록

TypeScript type과 interface 차이

TypeScript에서 객체의 타입을 정의하다 보면 typeinterface 중 무엇을 써야 할지 고민하게 된다.
둘은 객체의 구조를 표현할 때 상당히 비슷하게 동작한다.
그래서 간단한 객체 타입만 보고 한쪽이 항상 더 좋다고 결론 내리기는 어렵다.

차이가 분명해지는 지점은 객체 밖의 타입을 표현할 때, 같은 이름의 선언을 다시 열 때, 여러 타입을 합치다가 속성이 충돌할 때다.
이 세 가지를 기준으로 보면 선택이 단순해진다.

객체 구조를 표현하는 공통점

typeinterface는 모두 객체가 가져야 할 속성과 메서드를 정의할 수 있다.

type UserType = {
  id: number
  name: string
  greet(): string
}

interface UserInterface {
  id: number
  name: string
  greet(): string
}

두 타입에는 같은 형태의 객체를 할당할 수 있다.

const userByType: UserType = {
  id: 1,
  name: '현',
  greet() {
    return `안녕하세요, ${this.name}입니다.`
  },
}

const userByInterface: UserInterface = userByType

TypeScript는 이름보다 구조를 중심으로 호환성을 판단한다.
UserTypeUserInterface의 이름은 다르지만 필요한 속성과 메서드가 같으므로 위 할당은 허용된다.

함수 매개변수, API 응답, 컴포넌트 props, 설정 객체처럼 정해진 객체 모양을 표현하는 일은 둘 다 잘한다.
제네릭도 양쪽에서 사용할 수 있다.

type TypeResponse<T> = {
  data: T
  status: number
}

interface InterfaceResponse<T> {
  data: T
  status: number
}

객체 하나를 정의하는 단계에서는 문법 취향과 팀 규칙에 따라 골라도 되는 경우가 많다.

type이 표현할 수 있는 범위

type은 이름 그대로 어떤 타입에든 별칭을 붙일 수 있다.
객체뿐 아니라 원시 타입, 리터럴 유니온, 튜플, 함수 타입에도 이름을 붙일 수 있다.

type UserId = string

type RequestStatus = 'idle' | 'loading' | 'success' | 'error'

type Coordinate = [number, number]

type Formatter = (value: number) => string

여러 형태 중 하나를 나타내는 유니온 타입도 type이 필요한 대표적인 경우다.

type ApiResult =
  | { ok: true; data: string[] }
  | { ok: false; message: string }

function printResult(result: ApiResult) {
  if (result.ok) {
    console.log(result.data)
    return
  }

  console.error(result.message)
}

interface는 객체 타입의 이름을 선언하는 문법이다.
따라서 RequestStatus처럼 문자열 리터럴 자체를 합치거나 Coordinate처럼 튜플 타입에 직접 별칭을 붙이는 용도로는 사용할 수 없다.

다만 type의 별칭은 런타임에 새로운 값이나 서로 구별되는 명목 타입을 만드는 기능이 아니다.

type UserId = string

const rawId: string = 'user-1'
const userId: UserId = rawId

UserId는 결국 string의 다른 이름이므로 일반 문자열과 구조적으로 호환된다.

interface의 선언 병합

interface는 같은 이름으로 다시 선언하면 각 선언의 멤버가 합쳐진다.
이 동작을 선언 병합이라고 한다.

interface AppConfig {
  apiUrl: string
}

interface AppConfig {
  timeout: number
}

const config: AppConfig = {
  apiUrl: 'https://api.example.com',
  timeout: 3000,
}

최종 AppConfig에는 apiUrltimeout이 모두 필요하다.
같은 이름의 속성을 여러 선언에 넣는다면 타입도 서로 호환되어야 한다.

interface AppConfig {
  apiUrl: string
}

interface AppConfig {
  apiUrl: number
  // 오류: 같은 이름의 속성은 같은 타입이어야 한다.
}

type은 같은 스코프에서 같은 이름으로 다시 선언할 수 없다.

type AppConfig = {
  apiUrl: string
}

type AppConfig = {
  timeout: number
}
// 오류: Duplicate identifier 'AppConfig'

선언 병합은 기존 라이브러리 타입에 속성을 보태는 모듈 확장이나 전역 타입 확장에서 특히 유용하다.
반대로 애플리케이션 내부의 닫힌 데이터 모델에서는 의도하지 않은 병합이 생길 여지도 있다.
확장 가능성이 요구사항인지부터 확인하고 interface의 열린 특성을 사용하는 편이 안전하다.

extends와 교차 타입

기존 객체 타입을 확장하는 방법도 둘 다 제공한다.
interfaceextends를 사용하고, type은 주로 & 교차 타입을 사용한다.

interface User {
  id: number
  name: string
}

interface Admin extends User {
  permissions: string[]
}
type User = {
  id: number
  name: string
}

type Admin = User & {
  permissions: string[]
}

Admin 예시는 모두 User의 속성과 permissions를 가진 객체를 표현한다.
겉으로 보이는 결과가 같아도 속성 이름이 충돌하면 처리 방식이 달라진다.

속성 충돌 처리의 차이

interface의 확장 관계에서 같은 속성이 서로 다른 타입을 요구하면 선언 지점에서 오류가 난다.

interface StringId {
  id: string
}

interface NumberId {
  id: number
}

interface InvalidAccount extends StringId, NumberId {}
// 오류: id 속성의 타입이 서로 같지 않다.

어떤 속성이 충돌했는지 타입을 합치는 순간 확인할 수 있다는 장점이 있다.

교차 타입은 두 조건을 그대로 동시에 만족하라고 요구한다.
그래서 서로 다른 속성 타입을 교차해도 타입 별칭 선언 자체는 만들어질 수 있다.

interface StringId {
  id: string
}

interface NumberId {
  id: number
}

type ImpossibleAccount = StringId & NumberId

declare const account: ImpossibleAccount

account.id
// id의 타입은 never

idstring이면서 동시에 number여야 하므로 실제로 가능한 값이 없고 결과는 never가 된다.
오류가 타입을 합치는 줄이 아니라 나중에 값을 만들거나 사용할 때 드러날 수 있다는 뜻이다.

교차 타입을 여러 겹 조합할 때는 같은 이름의 속성이 서로 모순되지 않는지 확인해야 한다.
객체 계층을 확장하면서 충돌을 일찍 발견하고 싶다면 interface extends가 읽기 편한 경우가 많다.

클래스와의 관계

클래스가 따라야 할 인스턴스 구조는 interface와 객체 형태의 type 모두로 검사할 수 있다.

interface Printable {
  print(): void
}

type Serializable = {
  serialize(): string
}

class Report implements Printable, Serializable {
  print() {
    console.log('report')
  }

  serialize() {
    return JSON.stringify({ type: 'report' })
  }
}

implements를 쓴다는 이유만으로 반드시 interface를 선택할 필요는 없다.
다만 클래스 중심 설계에서 extends로 객체 계약의 계층을 드러내려면 interface가 자연스럽게 읽힐 수 있다.

실무 선택 기준

유니온, 리터럴, 튜플, 함수 타입처럼 객체 하나의 구조를 넘어서는 타입에는 type을 쓴다.
기존 타입을 조합해 새로운 타입을 만드는 작업이 많을 때도 type이 편하다.

외부에서 보강할 수 있는 공개 객체 계약이나 선언 병합이 필요한 라이브러리 타입에는 interface를 고려한다.
객체 계약의 상속 관계를 extends로 분명하게 보여주고 싶을 때도 interface가 잘 맞는다.

일반적인 내부 객체 모델은 둘 다 가능하다.
React props나 API 응답 객체라고 해서 반드시 한쪽만 정답인 것은 아니다.
이미 프로젝트가 한 가지 방식으로 일관되게 작성되어 있다면 그 규칙을 유지하는 편이 코드를 읽기 쉽다.

충돌 가능성도 선택 기준에 넣는다.
확장 과정의 속성 충돌을 선언 시점에 분명하게 확인하고 싶다면 interface extends가 유리하다.
교차 타입을 사용할 때는 충돌한 속성이 never로 좁혀질 수 있다는 점을 기억해야 한다.

정리하면 객체 모양만 필요할 때는 둘 중 익숙한 방식을 써도 된다.
객체 밖의 타입을 표현해야 하면 type, 선언을 다시 열어 확장해야 하면 interface를 고르면 된다.
둘을 합치는 과정에서는 문법보다 충돌 처리 방식까지 보고 결정하는 것이 중요하다.

참고 자료

관련 포스트
JavaScript ==와 === 차이 thumbnail
JavaScript ==와 === 차이
JavaScript ==와 ===의 차이를 암시적 타입 변환, null과 undefined, boolean과 문자열, 객체 참조, NaN, 비교 연산자 선택 기준으로 정리합니다.
marked.js 사용법과 기본 설정 thumbnail
marked.js 사용법과 기본 설정
marked.js 사용법을 초보자 기준으로 정리합니다. marked.parse와 parseInline, gfm과 breaks 옵션, 브라우저 출력, HTML sanitize 주의점까지 함께 봅니다.
JavaScript var let const 차이 thumbnail
JavaScript var let const 차이
JavaScript var, let, const 차이를 함수와 블록 스코프, 재할당과 재선언, 호이스팅과 TDZ, 반복문 closure, 객체 변경 기준으로 정리합니다.
URL path parameter와 query string 차이 thumbnail
URL path parameter와 query string 차이
URL path parameter와 query string의 차이를 초보자 기준으로 정리합니다. 리소스 식별자, 조회 조건, URLSearchParams, 인코딩, API 설계 기준을 함께 봅니다.
쿠키와 localStorage 차이 thumbnail
쿠키와 localStorage 차이
쿠키와 localStorage의 차이를 초보자 기준으로 정리합니다. 서버 자동 전송, JavaScript 접근, 만료 시간, HttpOnly, Secure, SameSite, 로그인 상태 저장 기준을 함께 봅니다.
script async와 defer 차이 thumbnail
script async와 defer 차이
HTML script 태그의 기본 동작, async와 defer의 다운로드 방식, 실행 순서, DOMContentLoaded와의 관계를 초보자 기준으로 정리합니다.
CORS 에러와 해결 기준 thumbnail
CORS 에러와 해결 기준
브라우저의 Same-Origin Policy, CORS 응답 헤더, preflight, 서버에서 해결해야 하는 이유를 초보자 기준으로 정리합니다.
fetch와 axios 차이 thumbnail
fetch와 axios 차이
fetch와 axios의 차이를 초보자 기준으로 정리합니다. 설치 여부, JSON 처리, HTTP 에러 처리, timeout, interceptor, 언제 어떤 방식을 쓰면 좋은지 함께 봅니다.
JavaScript map과 forEach 차이 thumbnail
JavaScript map과 forEach 차이
JavaScript의 map과 forEach 차이를 반환값, 새 배열, 부수 효과, 원본 변경, 비동기 콜백, Promise.all 사용 기준으로 나누어 정리합니다.
Vue 3 입문기 thumbnail
Vue 3 입문기
Vue의 메인 버전이 3가 된지 꽤 됐다. 작성 당시에는 Nuxt 3가 RC 단계였기 때문에 실서비스에 바로 적용하기에는 조심스러웠다. 그래서 궁금하고 심심하던 참에 간단한 Todo App을 만들어봤다.
marked.js renderer custom 하기 thumbnail
marked.js renderer custom 하기
marked.js renderer custom 방법을 정리합니다. Renderer의 역할, image와 link 렌더러 커스텀, alt와 rel 처리, sanitize-html 같은 HTML 정화 주의점까지 함께 봅니다.
가독성 있게 상수 넣기 thumbnail
가독성 있게 상수 넣기
오늘 회사 동료의 PR 리뷰 과정에서 좋은 기능을 공유해주셔서 TIL로 남겨봅니다. 아래와 같이 상수에 언더바(_)로 콤마처럼 구분을 시켜줄 수 있습니다. 앞으로 깔끔하고 좋은 코드를 작성하기 위해 자주 사용해야겠습니다 :)
TS에서 generic optional 하게 설정하기 thumbnail
TS에서 generic optional 하게 설정하기
오늘 next에서 `getStaticProps`와 `getLayout` 패턴을 함께 사용할 때, typescript generic을 넘겨주는 작업을 하고 있었는데, 기본값이 없다보니 기존 코드에 에러가 발생했었다. 이를 해결하기 위해 찾아보니 단순히 아래 예시처럼 `= {}`을 추가해주면 해결된다고 한다.
Backend에서 API Response가 snake_case인 경우엔? thumbnail
Backend에서 API Response가 snake_case인 경우엔?
안녕하세요. 프론트엔드 개발자의 경우, 가끔 백엔드의 API Response 값이 snake_case일 경우 어떻게 관리할지에 대해 고민에 빠지게 됩니다. 저도 오늘 같은 상황을 겪게 되었는데,  이번엔 네이밍 컨벤션을 맞춰주기로 했습니다. 컨벤션을 맞추는 데에는 여러가지 방법이 있겠지만, 고민 끝에 저는 axios의 interceptors를 통해 해결을 해보았습니다.
Promise 다루기 (feat. 병렬실행, 순차실행) thumbnail
Promise 다루기 (feat. 병렬실행, 순차실행)
오늘은 Promise를 통해 구문을 동기 처리 할 때, 여러 Promise들을 다루는 법을 소개해보겠습니다. Javscript를 작성하다 보면, 가끔 여러 Promise들을 다룰 때가 있습니다. 필자도 Nodejs 서버에서 동시에 여러 쿼리를 실행할 때 자주 마주쳤었는데요. 오늘은 어떻게 하면 Promise들을 유연하게 다룰 수 있는지 알아보겠습니다. 시작하기 앞서, 네 가지 Promise를 선언하고, 그들을 하나의 Array에 묶어보겠습니다.