TIL 23. 12. 19

인터페이스

인터페이스란 타입 별칭과 동일하게 타입에 이름을 지어 주는 또 다른 문법이다. 인터페이스는 특히 객체 구조를 정의하는데 특화되어 타입 별칭과는 문법만 조금 다를 뿐 기본적인 기능은 거의 동일하다.

interface Person {
  readonly name: string;
  age?: number;
  welcome: () => void;
}

인터페이스는 메서드의 타입을 함수 타입 표현식 대신 호출 시그니쳐를 이용해 정의할 수 있다. 함수 타입 표현식으로는 메서드의 오버로딩 구현이 불가능하므로 호출 시그니쳐를 이용해 메서드의 타입을 정의한다.

interface Person {
  readonly name: string;
  age?: number;
  welcome(): void;;
  welcome(date: number): void;
}

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

인터페이스는 대부분의 상황에서 타입 별칭과 동일하게 동작하지만 몇 가지 차이점이 있다.

1. 타입 별칭은 모든 타입에, 인터페이스는 객체 타입에만 가능하다.

숫자, 문자열, 불 등과 같은 원시 타입에는 인터페이스를 사용할 수 없다. 인터페이스는 오직 객체의 구조를 정의하는 데에만 사용된다.

interface Obj {
  value: string
}

type UserName = string

interface X extends string { ... } // ❌

2. 인터페이스로 만든 타입은 유니온이나 교집합 타입으로 정의할 수 없다.

type Type1 = number | string;
type Type2 = number & string;

interface Type3 {
  name: string;
} | string; // ❌ 

인터페이스로 만든 타입을 Union 또는 Intersection으로 이용해야 한다면 타입 별칭과 함께 사용하거나 타입 주석에서 직접 사용해야 한다.

type Type1 = number | string | Type3;
type Type2 = number & string & Type3;

const typeVar: Type3 | string = {
  name: '',
}

3. 타입 별칭은 새 프로퍼티를 추가하도록 열려 있지 없으나, 인터페이스의 경우 확장될 수 있다.

인터페이스는 기존에 선언된 타입을 확장해서 다른 속성을 추가로 선언할 수 있다. 컴파일러가 같은 이름으로 선언된 두 개의 개별적인 선언을 하나의 정의로 병합한다.

interface Person {
  name: string;
}

interface Person {
  age: number;
}

const person1: Person { // ✅
  name: 'Kim',
  age: 20
}

type Person = {
  name: string;
}

type Person = { // ❌
  age: number;
}

Types vs. interfaces in TypeScript

인터페이스 확장

인터페이스 확장이란 하나의 인터페이스를 다른 인터페이스들이 상속받아 중복된 프로퍼티를 정의하지 않도록 도와주는 문법이다.

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

interface Dog {
  name: string;
  age: number;
  isBark: boolean;
}

interface Cat {
  name: string;
  age: number;
  isScratch: boolean;
}

Dog과 Cat 타입의 name과 age 프로퍼티가 중복되어 정의되어 있다. 인터페이스의 확장 기능을 사용하면 프로퍼티의 중복을 제거할 수 있다.

interface Animal {
  name: string;
  color: string;
}

interface Dog extends Animal {
  breed: string;
}

interface Cat extends Animal {
  isScratch: boolean;
}

extends 뒤에 확장할 타입의 이름을 정의하면 해당 타입에 정의된 모든 프로퍼티를 상속 받는다.

확장받는 타입에서 프로퍼티의 타입을 재정의 할 수도 있다. 단, 재정의한 타입은 원본 타입의 서브 타입이어야 한다. A 타입이 B 타입으로 확장한다는 것은 A 타입이 B 타입의 서브 타입이 된다는 것을 의미하기 때문이다.

Dog의 name 프로퍼티 값의 타입을 number 타입으로 재정의 한다면 더 이상 Animal의 서브 타입이 아니게 된다. 따라서 이렇게 재정의 할 수는 없다.

interface Animal {
  name: string;
  color: string;
}

interface Dog extends Animal {
  name: '멈뭄미',
  breed: string;
}

인터페이스는 인터페이스 뿐만 아니라 타입 별칭으로 정의된 객체도 확장할 수 있다.

type Animal = {
  name: string;
  color: string;
};

interface Dog extends Animal {
  breed: string;
}

인터페이스 선언 합치기

타입 별칭은 동일한 스코프 내에 중복된 이름으로 선언할 수 없는 반면 인터페이스는 가능하다.

type Person = {
  name: string;
};

type Person = { 
  age: number;
};

중복된 이름으로 선언된 인터페이스는 모두 하나로 합쳐진다. 이를 선언 합침이라고 부른다.

interface Person {
  name: string;
}

interface Person { // ✅
  age: number;
}

// 선언 합침 결과
interface Person {
  name: string;
  age: number;
}

그런데 동일한 프로퍼티의 타입을 다르게 정의하는 것은 허용하지 않는다.

interface Person {
  name: string;
}

interface Person {
  name: number; // ❌
  age: number;
}

선언 합침은 라이브러리의 타입을 추가하는 ‘모듈 보강’을 위해 사용한다. 사용하는 라이브러리는 이미 타입 정의가 완료되어 있으므로 임의로 추가할 수 없다. 이때 인터페이스 선언 합침을 사용해 인터페이스를 재정의하고 프로퍼티를 추가하여 보강한다.

제네릭

제네릭이란 함수나 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동작하도록 만들어 주는 타입스크립트의 놀라운 기능 중 하나이다.

제네릭이 필요한 이유를 살펴보자. 먼저 다양한 타입의 매개변수를 받고 그대로 반환하는 함수를 정의한다.

function func(value: any) {
  return value;
}

let num = func(10); // num: any

num의 타입은 func의 인자로 넘겨준 number 타입이 되기를 희망하지만 함수의 반환값 타입은 return 문을 기준으로 자동으로 추론 되어 value의 타입인 any가 그대로 반환된다.

따라서, number 타입인 10이 저장된 num 변수에 String 타입의 메서드를 사용해도 오류를 감지하지 못하고 런타임에 오류가 발생한다.

function func(value: any) {
  return value;
}

let num = func(10);
num.toUpperCase(); // 런타임에 에러 발생

이번에는 매개변수의 타입을 any 타입이 아닌 unknown 타입으로 정의해보자.

function func(value: unknown) {
  return value;
}

let num = func(10);
num.toUpperCase(); // ❌ Erorr
num.toFixed(); // ❌ Erorr

다른 타입의 메서드 호출은 방지할 수 있게 되었으나 num의 타입이 unknown이므로 Number 타입의 메서드도 사용할 수 없게 되었다. 결국 변수 num이 number 타입임을 명시해야 한다.

function func(value: unknown) {
  return value;
}

let num = func(10);
if (typeof num === 'number') num.toFixed();

변수 num에 할당되는 값의 타입은 number 임이 분명한데 비효율적으로 타입을 좁혀 사용해야 한다. 이러한 동작 방식보다 전달되는 값의 타입에 따라 자동으로 반환값의 타입을 추론하길 바란다. 이럴 때 제네릭을 이용한다.

제네릭 함수

제네릭 함수는 두루두루 모든 타입의 값을 다 적용할 수 있는 범용적인 함수라고 생각하면 쉽다. 다음과 같이 제네릭 함수를 선언할 수 있다.

function func<T>(value: T): T {
  return value;
}

let num = func(10); // num: number

함수 이름 뒤에 꺾쇠를 열고 타입을 담는 변수인 타입 변수 T를 선언한다. 그리고 매개변수와 반환값의 타입을 타입 변수 T로 설정한다.

함수가 호출될 때 T에 어떤 타입이 할당될 지 결정된다. func(10)처럼 Number 타입의 값을 인수로 전달하면 매개변수 value에 Number 타입의 값이 저장되면서 T가 Number 타입으로 추론된다. 이때 반환값 타입 역시 Number 타입이 된다.

notion image

제네릭 함수를 호출하는 다른 방법도 있다. 함수에 타입 인수를 포함한 모든 인수를 전달하는 방식이다.

function func<T>(value: T): T {
  return value;
}

let arr = func<number>(10);

이때는 T에 number 타입이 먼저 할당되고 매개변수 value와 반환값 타입이 number 타입으로 추론된다. 타입 변수에 할당하고 싶은 특정 타입이 존재한다면 직접 명시 해주는 것이 좋으나 대다수의 상황에서는 알아서 잘 추론 되므로 선택적으로 사용하도록 한다.

제네릭 인터페이스, 제네릭 타입 별칭

제네릭은 인터페이스와 타입 별칭에도 적용할 수 있다.

제네릭 인터페이스는 제네릭 함수와는 달리 변수의 타입으로 정의할 때 반드시 꺾쇠와 함께 타입 변수에 할당할 타입을 명시해주어야 한다.

interface KeyPair<K, V> {
  key: K;
  value: V;
}

let keyPair: KeyPair<string, number> = {
  key: 'abc',
  value: 0,
}

제네릭 인터페이스는 인덱스 시그니쳐와 함께 사용하면 더 유연한 객체 타입을 정의할 수 있다.

interface Map<V> {
  [key: string]: V;
}

let stringMap: Map<string> = {
  key: 'key'
}

let numberMap: Map<number> = {
  key: 0
}

제네릭 타입 별칭을 사용할 때도 제네릭 인터페이스와 마찬가지로 타입으로 정의될 때 반드시 타입 변수에 설정할 타입을 명시해 주어야 한다.

type Map2<V> = {
  [key: string]: V;
};

let stringMap2: Map2<string> = {
  key: "string",
};

카테고리:

업데이트:

댓글남기기