Posts
typescript
TypeScript - Interface

interface vs. type

type 키워드로 타입 별칭(Type alias)을 선언하는 것처럼, interface 로도 타입을 선언할 수 있다. 둘은 문법이 다를 뿐, 거의 같은 기능을 수행한다. 하지만 세세한 부분에 차이점이 존재한다.

1. interface 키워드 옆에는 형태가 온다

타입 별칭(type 키워드)은 더 일반적이어서,

타입별칭 오른쪽에는 타입 표현식(타입, &, | 등의 타입 연산자)을 포함한 모든 타입이 올 수 있다.

반면, interface 키워드 옆에는 반드시 형태가 와야한다!

type은 복잡한 타입 표현식에 이름을 붙여주는 것과 같고,

interface 는 객체의 형태를 정의하기 위한 방법으로 설계되었다.

type SomethingAlias = number | string
 
interface SomethingShape {
	nickName: string;
	level: number;
} //형태가 온다.

여기서, 타입 표현식이란?

typescript 공식문서에 나오는 개념은 아니지만, 식(Expression))과 문(Statement) 개념으로 이해할 수 있다.

먼저 프로그래밍에서 "식"과 "문"에 대한 개념을 정리하면,

  • 식(Expression): 식은 값을 만들어낸다. 예를 들어, 2 + 2는 4라는 값을 만들어내므로 식이다. 또는 x도 변수 x의 값을 만들어내므로 식. let y = 2 + 2;에서 2 + 2; 부분도 식.
  • 문(Statement): 문은 일련의 작업을 수행하지만, 자체적으로는 값을 만들어내지 않는다. 문은 일반적으로 프로그램의 최상위 레벨 또는 블록 { ... } 내에서 위치한다. 예를 들어, let x = 2 + 2;x라는 이름의 변수를 선언하고, 2 + 2라는 식의 결과값인 4x에 할당하는 작업을 수행하는 문.

타입 표현식(type expression)과 타입 문(type statement) 개념도 비슷함.

  • 타입 표현식(Type Expression): 타입 표현식은 타입을 나타내는 식. 즉, 기본 타입, 배열 타입, 유니온 타입 등과 타입연산자는 모두 타입 표현식의 일부가 될 수 있다. 예를 들어, string이나 number | string 등은 타입 표현식Array<string>처럼 다른 타입 표현식을 포함하는 것도 가능.
  • 타입 문(Type Statement): 타입 문은 타입을 정의하거나 이름을 붙이는 등의 작업을 수행하지만, 자체적으로는 타입을 나타내지 않는다. 예를 들어, type MyString = string;MyString이라는 새 타입을 선언하고, string 타입을 **MyString에 연결하는 타입 문.

물론, 같은 형태를 정의한다면 type으로 선언한 자리에 interface로 선언한 타입이 올수 있다.

type Sushi = {
	calories: number;
	salty: boolean;
	price: number;
}
 interface Sushi {
 	calories: number
	salty: boolean
	price: number
}
 
const unagiSushi: Sushi = {
	calories: 11
	salty: 15;
	price: 20
}

2. 타입을 조합하는 방법

타입을 조합할 때는 조금 다른 방식이 적용된다.

type Cake = {
	calories: number
	sweet: boolean
  price: boolean
}

Cake , Sushi 는 공통의 프로퍼티를 포함하기 때문에 Food라는 상위 카테고리에 해당하는 타입을 선언하고 이를 이용해서 다시 정의할 수 있다.

type alias with intersection(& )

type Food = {
	calories: number
	price: number
}
 
type Sushi = Food & {
	salty: boolean
}
 
type Cake = Food & {
 sweet: boolean
}

type alias로 선언한 경우, & 연산자를 통해 타입을 합친다.

Interface with extends

interface의 경의 extends 키워드로 타입 선언 시 다른 인터페이스를 상속받을 수 있다.

이 때 항상 Interface로 선언한 것만 가능한 것이 아니라, 객체타입, 클래스도 가능.

interface Food {
	calories: number
	price: number
}
 
interface Sushi extends Food {
	salty: boolean
}
 
interface Cake extends Food {
 sweet: boolean

음.. 그럼 사용하는 문법만 다르고 하는 역할은 똑같은거 아니야? 라고 생각할 수 있다.

위의 예시에서는 결과적으로 Sushi, Cake 프로퍼티의 차이는 없지만, 둘을 사용하는 조건마다 미묘한 차이가 있다. 계속 차이점을 알아보자.

3. 상속할 때 확인하는 것

타입스크립트는 인터페이스를 상속할 때, 상속받는 인터페이스의 타입에 상위 인터페이스를 할당할 수 있는지 확인한다. 예를 들어,

interface A {
	getString(x: number): string
	getNumber(x: number): number
}
 
interface B extends A {
	getString(x: string | number): string // OK (number가 할당될 수 있음)
	getNumber(x: string): string // error
}
Interface 'B' incorrectly extends interface 'A'.
  Types of property 'getNumber' are incompatible.
    Type **'(x: string) => string' is not assignable to type '(x: number) => number'.**
      Types of parameters 'x' and 'x' are incompatible.
        Type **'number' is not assignable to type 'string' 
   => A 인터페이스의 nubmer 타입 파라미터가 B 인터페이스의 string 타입 파라미터에 할당될 수 없음을 경고.**

하지만, intersection 타입을 사용하는 경우 타입스크립트는 최대한 타입들을 조합하는 방식으로 결과를 낸다.

위와 같은 예시에서 type 을 사용하면, 컴파일 에러가 발생하지 않고, 오버로드 시그니처가 만들어진다.

type A = {
	getString(x: number): string
	getNumber(x: number): number
}
 
type B = A & {
	getString(x: string | number): string 
	getNumber(x: string): string
}

TS Playground - An online editor for exploring TypeScript and JavaScript (opens in a new tab)

4. 같은 이름의 타입 선언 가능 여부 - 선언 병합

같은 범위에 같은 이름의 Type alias 선언은 불가능하지만, interface는 가능하다. 이때 여러개의 같은 이름을 가진 인터페이스들은 자동으로 합쳐진다. 이를 *선언 병합(declaration merging)*이라고 한다.

선언 병합은 열거형 타입(Enum)에서도 사용되는 타입스크립트 기능이다.

interface User {
	name: string
}
 
interface User {
	age: number
}
 
interface User {
	email: string
}
 
const elice: User = {
	name: 'elice',
	age: 8,
	email: 'elice@elicer.com'
}

같은 코드를 type 키워드를 사용하여 표현하면 중복된 Duplicate identifier 'User' 와 같이 에러가 발생한다.

하지만 이렇게 유연성 있어보이는 interface도 사용할 때 주의할 점이 있다.

병합 시 같은 프로퍼티 간의 충돌

interface User {
	name: string;
}
 
interface User {
	name: string | boolean; 
// Error: Subsequent property declarations must have the same type. 
// Property 'name' must be of type 'string', but here has type 'boolean'.(2717)
// input.tsx(32, 2): 'name' was also declared here.
}

위에서 같은 프로퍼티인 name 에 새로운 타입을 할당하면서 충돌이 발생했다. 같은 프로퍼티를 다른 곳에서 선언하여 병합 시, 반드시 같은 프로퍼티는 같은 타입을 가져야한다.

따라서, name: boolean | string 과 같이 선언해줘도 에러는 발생한다.

TS Playground - An online editor for exploring TypeScript and JavaScript (opens in a new tab)

제네릭으로 interface를 선언했다?

일단 잘 병합되지 않는다. 하지만 제네릭의 선언 방법, 같은 타입의 제네릭 매개변수를 가지면 병합된다.

interface User<Name extends string>{
	name: Name;
}
 
interface User<Name extends boolean>{
	name: Name;
} 
// All declarations of 'User' must have identical type parameters

예를 들어, 아래와 같이 **MyInterface<T>를 두 번 선언하고 동일한 제네릭 파라미터 **T를 사용하는 경우 병합이 발생한다.

TS Playground - An online editor for exploring TypeScript and JavaScript (opens in a new tab)

interface MyInterface<T> {
  prop1: T;
}
 
interface MyInterface<T> {
  prop2: T;
}
 
const obj: MyInterface<string> = {
  prop1: "Hello",
  prop2: "World",
};

위의 예시에서는 **MyInterface<T>를 두 번 선언했지만, **T가 동일하므로 타입스크립트가 자동으로 이 두 인터페이스를 병합하여 하나의 인터페이스로 취급한다.

결과적으로 **obj 객체는 **prop1과 **prop2를 모두 포함하는 타입을 갖게 됨.

💡

다만, 제네릭 파라미터를 사용하는 복잡한 경우에는 병합이 제대로 이루어지지 않을 수 있으며, 이러한 경우에는 별도의 방법을 사용해야 할 수도 있습니다. 제네릭을 사용하는 복잡한 인터페이스 병합이 필요한 경우에는 인터페이스를 확장한 타입 별칭을 사용하거나, 유니언 타입 등을 활용하여 원하는 병합 결과를 얻을 수 있습니다.

5. 클래스와 사용하기

클래스를 선언할 때 implements 키워드로 특정 인터페이스를 만족시킨다는 것을 표현할 수 있다.

다른 명시적 타입 어노테이션처럼, implements 으로 타입 수준의 제한을 추가하는 것.

특히 디자인 패턴 구현시 많이 사용된다.

interface Developer {
	name: string;
	language: string[];
	sleep(hours: number): void;
	work(hours: number): void;
}
 
class EliceDeveloper implements Developer {
		name: 'elicer'
		language: ['TypeScript', 'JavaScript', 'Python']
		sleep(hours: number) {
			console.log(`Slept for ${hours} hours`);
			if (hours > 10) console.log('you are late!! wake up!')
		}
		work(hours: number) {
			console.log(`worked for ${hours} hours`);
			if (hours > 13) console.log('you can take a taxi onn your way home from work.')
		}
		
}

여기서 Developer 를 구현하는 ElicerDeveloperDeveloper 의 모든 메서드를 구현해야 하며, 추가로 필요한 메서드, 프로퍼티를 구현할 수 있다.

또한, 하나의 클래스가 여러 인터페이스를 구현할 수도 있다.

enum Project {
	landing,
	LXP,
	test,
	works,
	designSystem,
	admin,
	account
}
 
interface PlatformTeam {
	squad: string;
	project: Project;
}
 
class EliceDeveloper implements Developer, PlatformTeam {
	name = 'elicer'
	language= ['TypeScript', 'JavaScript', 'Python']
	sleep(hours: number) {
		console.log(`Slept for ${hours} hours`);
		if (hours > 10) console.log('you are late!! wake up!')
	}
	work(hours: number, /*hasHotfix: boolean */) {
		console.log(`worked for ${hours} hours`);
		if (hours > 13) console.log('you can take a taxi onn your way home from work.')
	}
	// PlatformTeam 구현 프로퍼티
	squad = 'Web B'
	project = Project.designSystem
	// 추가한 메서드와 프로퍼티.
	deploy(issueCount: number ) {
		issueCount < 3 ?  console.log('칼퇴') : console.log('야근')
	}
}

(opens in a new tab)

이렇게 implements 키워드를 이용하면 타입 안정성을 보장받을 수 있다.

⇒ 프로퍼티를 빼먹거나 리턴하는 타입이 다른 메소드 등에 타입스크립트가 지적해준다.

type alias와 interface 차이점 정리.

interface

  • 주로 객체의 형태(shape)를 설명하는 데 사용된다.
  • 선언 병합(declaration merging)이 가능.
  • 확장(extends) 및 구현(implements)이 가능.

**interface의 우측에 오는 것은 구체적인 객체의 형태를 나타내는 속성과 메서드의 목록이며, 타입 표현식이 X

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

type alias

**type 별칭은 새로운 타입의 이름을 선언하는 데 사용되며, 기존 타입에 대한 참조 또는 더 복잡한 타입 표현식을 단순화하는 데 사용될 수 있음. **type 별칭은 타입 표현식에 이름을 붙이는 것이므로, 별칭의 우측에는 타입 표현식이 오게됨.

type StringOrNumber = string | number;

이처럼 **interface와 **type은 각각의 문맥에서 특정 형태의 타입을 나타내는 방법으로 사용됩니다. **interface는 객체의 형태를 나타내는 구조를 제공하고, **type은 기존 타입에 이름을 붙이거나 복잡한 타입을 단순화하는데 사용됩니다. 이런 차이 때문에 **interface와 **type 우측에 올 수 있는 것이 다릅니다.

inteface 구현(implements) vs. 추상 클래스 상속 (extends)

인터페이스 구현은 추상 클래스 상속과 아주 비슷하다. 어떠한 차이점이 있을까?

  • 인터페이스는 더 범용으로 쓰이며 가볍고,

    • 가볍다: 자바스크립트 코드를 만들지 않고 컴파일 타임에만 존재한다.
  • 추상 클래스는 특별한 목적과 풍부한 기능을 갖는다.

    • 런타임의 자바스크립트 클래스 코드를 만든다.
    • 생성자와 기본 구현을 가질수 있다.
    • 프로퍼티와 메서드에 접근 한정자를 지정할 수 있다.
  • class 접근 한정자

    타입스크립트에는 class에 접근 제한자(Access modifier)인 publicprotectedprivate를 지원하며, 이를 통해 외부에서 특정 메서드나 프로퍼티에 접근 범위를 지정할 수 있다.

    타입스크립트만의 고유 기능이므로 컴파일 타임에만 존재하며, 응용 프로그램을 자바스크립트로 컴파일 할 때는 아무 코드도 생성하지 않는다.

    물론 es2019에서 private 필드를 선언할 수 있는 방법이 추가되긴 했지만… (opens in a new tab)

    • public : 어디에서나 접근 가능함. (기본 한정자)
    • protected: 이클래스, 서브 클래스의 인스턴스에서만 접근 가능.
    • private: 이 클래스의 인스턴스에서만 접근 가능.

언제 뭘 사용해야할까?

여러 클래스에서 공유하는 구현 → 추상 클래스 사용

가볍게 이 클래스는 T다 → 인터페이스 사용

구조기반 타입을 지원하는 class

타입스크립트는 클래스를 비교할 때, 타입 이름이 아닌 구조를 확인한다.

즉, 클래스는 자신과 똑같은 프로퍼티, 메서드를 정의하는 일반 객체 및 클래스의 형태를 공유하는 다른 모든 타입과 호환된다.

class Javascript {
	write() {
	// ...
	}
}
 
class TypeScript {
	write()
}
	
// JavaScript class 타입을 매개변수로 받는 함수
const develop = (language: Javascript) => {
	language.write()
}
 
const typescript = new TypeScript;
 
develop(typescript); // OK

단, private이나 protected 필드를 갖는 클래스의 경우엔 달라질 수 있다.

지정된 클래스나 서브클래스의 인스턴스가 아니라면 할당할 수 없게된다.

class A {
	private age = 30
}
 
class AB extends A {}
 
const run = (person: A) => {
}
 
run(new A) // OK
run(new AB) // OK
run({age: 30}) // error

예시 - interface 확장하기

서드파티 라이브러리를 사용할 때, 해당 라이브러리에서 정의한 타입에 프로퍼티를 추가하고 싶은 경우가 있을 수 있다.

예를 들어, MUI와 같은 UI라이브러리는 이미 스타일까지 입혀진 UI 컴포넌트를 지원하지만, 사용자가 커스텀할 수 있는 다양한 방법을 지원한다.

특히, 특정 컴포넌트의 프롭을 추가하거나 특정 프롭의 선택지를 추가하는 것에서 선언 병합(declaration merging)이 아주 유용하게 사용될 수 있다

(opens in a new tab)

예를 들어, Chip 컴포넌트 타이핑을 살펴보면

export interface ChipPropsVariantOverrides {}
 
export interface ChipPropsSizeOverrides {}
 
export interface ChipPropsColorOverrides {}
 
export interface ChipTypeMap<P = {}, D extends React.ElementType = 'div'> {
  props: P & {
    /**
     * The Avatar element to display.
     */
    avatar?: React.ReactElement;
    /**
     * This prop isn't supported.
     * Use the `component` prop if you need to change the children structure.
     */
    children?: null;
    /**
     * Override or extend the styles applied to the component.
     */
    classes?: Partial<ChipClasses>;
    /**
     * If `true`, the chip will appear clickable, and will raise when pressed,
     * even if the onClick prop is not defined.
     * If `false`, the chip will not appear clickable, even if onClick prop is defined.
     * This can be used, for example,
     * along with the component prop to indicate an anchor Chip is clickable.
     * Note: this controls the UI and does not affect the onClick event.
     */
    clickable?: boolean;
    /**
     * The color of the component.
     * It supports both default and custom theme colors, which can be added as shown in the
     * [palette customization guide](https://mui.com/material-ui/customization/palette/#adding-new-colors).
     * @default 'default'
     */
    **color?: OverridableStringUnion<
      'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning',
      ChipPropsColorOverrides
    >;**
    /**
     * Override the default delete icon element. Shown only if `onDelete` is set.
     */
    deleteIcon?: React.ReactElement;
    /**
     * If `true`, the component is disabled.
     * @default false
     */
    disabled?: boolean;
    /**
     * Icon element.
     */
    icon?: React.ReactElement;
    /**
     * The content of the component.
     */
    label?: React.ReactNode;
    /**
     * Callback fired when the delete icon is clicked.
     * If set, the delete icon will be shown.
     */
    onDelete?: React.EventHandler<any>;
    /**
     * The size of the component.
     * @default 'medium'
     */
    **size?: OverridableStringUnion<'small' | 'medium', ChipPropsSizeOverrides>;**
   /**
     * If `true`, allows the disabled chip to escape focus.
     * If `false`, allows the disabled chip to receive focus.
     * @default false
     */
    skipFocusWhenDisabled?: boolean;
    /**
     * The system prop that allows defining system overrides as well as additional CSS styles.
     */
    sx?: SxProps<Theme>;
    /**
     *  @ignore
     */
    tabIndex?: number;
    /**
     * The variant to use.
     * @default 'filled'
     */
    **variant?: OverridableStringUnion<'filled' | 'outlined', ChipPropsVariantOverrides>;**
  };
  defaultComponent: D;
}
 
export type ChipProps<
  D extends React.ElementType = ChipTypeMap['defaultComponent'],
  P = {},
> = OverrideProps<ChipTypeMap<P, D>, D>;
 

variant, size, color에 커스텀 프로퍼티를 사용할 수 있도록 구성되어있다.

예를 들어, EDS에서는 muted variant를 추가하여 사용하고 있다.

**declare module '@mui/material/Button' {// module augmentation**
	interface **ChipPropsVariantOverrides {
		muted: true;
	}
 
  interface {
 
  }
}**

mui 코드베이스에 있는 ChipPropsVariantOverrides 는 프로퍼티가 비어있는 형태이지만, 개발자가 선언 병합을 통해 프로퍼티를 추가할 수 있기 때문에 우리는 새로운 variant, color 등을 추가할 수 있는 것이다!

참고로, ChipProps는 다른 컴포넌트들과 다르게 Interface가 아닌 type 키워드로 선언되어서 prop을 추가할 수 없었다.

shape: ‘squared’ | ‘circular’ 를 추가하고 싶었으나… 불가능….

그래서 variant에 squared 를 추가했지만 다른 variant들과 성격이 다르기 때문에 별도의 컴포넌트를 추가했다.

현재 개발중인 프로젝트 내부에 있는 타입에 프로퍼티를 추가하고 싶으면 type alias 를 사용하거나 새 interface를 선언해도 가능하지만,

이처럼 라이브러리에서 사용중인 타입에 변화를 줄 때는 interface의 선언 병합이 매우 유용하다.

(덕분에 material-ui에 기본적으로 제공하는 팔레트 컬러 말고도 다양한 컬러들을 추가하여 사용할 수 있다.)

Palette - Material UI (opens in a new tab)