타입스크립트 고급 타입 정의 방법 / 인터페이스, 클래스, 제네릭 타입 등

인터페이스

인터페이스는 클래스가 구현해야 할 속성 및 메서드 구조를 정의하는 설계도 역할입니다.

인터페이스 타입 정의

// 인터페이스로 객체 타입 정의
interface 인터페이스명 {
  프로퍼티명1: string;
  프로퍼티명2: number;
}

// 인터페이스 타입에 맞는 객체 정의
const 변수명: 인터페이스명 = {
  프로퍼티명1: "한서윤",
  프로퍼티명2: 25
}

인터페이스로 객체 타입 정의 시 상속, 선언 병합 기능을 이용할 수 있습니다.

인터페이스 정의 시 함수 프로퍼티 추가

// 인터페이스로 객체 타입 정의
interface 인터페이스명 {
  프로퍼티명: () => void; // 함수 타입 표현식으로 함수 프로퍼티 선언 가능 (1개만 선언 가능. 오버로드 불가)
  또는
  프로퍼티명(): void; // 함수 호출 시그니처 형태로 함수 프로퍼티 선언 가능
  프로퍼티명(매개변수명: number): void; // 함수 호출 시그니처 문법 이용 시 여러 오버로드 시그니처 정의 가능
}

// 인터페이스 타입에 맞는 객체 정의
const 변수명: 인터페이스명 = {
  프로퍼티명: function () {
    console.log("Hi");
  }
};

함수 프로퍼티를 갖는 인터페이스 정의할 수 있습니다.

인터페이스 상속 (extends)

// 부모 인터페이스 타입 정의
interface Person {
  name: string;
}
또는
// 부모 객체 타입 정의
type Person = {
  name: string;
}

// 자식 인터페이스 타입 정의
// 부모 인터페이스 타입 또는 부모 객체 타입 프로퍼티 상속
interface Student extends Person {
  grade: number;
}
interface Developer extends Person {
  name: "개발자"; // 스트링 리터럴 타입 (부모 타입 string의 서브 타입)
  grade: number;
}

부모 인터페이스 타입 프로퍼티들을 상속한 자식 인터페이스 타입을 정의할 수 있습니다.
상속을 이용하면 자식 타입 정의 시 중복 속성 코드 작성을 최소화할 수 있습니다.
부모에서 상속받은 프로퍼티 재정의 시, 원본 타입의 서브타입이어야 합니다.

인터페이스 다중 상속

interface 자식인터페이스명 extends 부모인터페이스명1, 부모인터페이스명2 {
  // 프로퍼티 정의
}

const 변수명: 자식인터페이스명 = {
  // 모든 부모, 자식 필수 프로퍼티 값 정의 필요
}

여러 부모 인터페이스 타입 프로퍼티들을 모두 갖는 자식 인터페이스 타입을 정의할 수 있습니다.

인터페이스 선언 병합

interface User {
  name: string;
}

interface User {
  age: number;
}

// 인터페이스 선언 병합 결과
interface User {
  name: string;
  age: number;
}

동일한 인터페이스명으로 인터페이스 타입들을 선언하면, 모든 프로퍼티들이 병합됩니다.
각 인터페이스에 같은 프로퍼티 정의 시에는 반드시 타입이 같아야 합니다.


클래스

클래스는 동일한 속성과 기능을 가진 여러 객체를 생성할 때 설계도 역할을 합니다.
클래스 활용 시 객체 코드 중복을 최소화 할 수 있습니다.

클래스 타입 정의

class 클래스명 {
  필드명1: number;
  필드명2: string = ""; // 기본값 설정
  필드명3?: boolean; // 선택적 프로퍼티

  constructor(필드명1: number, 필드명2: string, 필드명3: boolean) {
    this.필드명1 = 필드명1;
    this.필드명2 = 필드명2;
    this.필드명3 = 필드명3;
  }

  메서드명() {
    
  }
}

각 필드 타입이 명시된 클래스를 정의할 수 있습니다.
타입스크립트 클래스는 타입으로 사용되어 변수 타입 안정성을 높일 수 있습니다.

Typescript 클래스 상속 방법

class 자식클래스명 extends 부모클래스명 {
  필드명4: number;

  constructor(필드명1: number, 필드명2: string, 필드명3: boolean, 필드명4: number) {
    super(필드명1, 필드명2, 필드명3);
    this.필드명4 = 필드명4;
  }
}

생성자에서 부모 클래스 생성자를 호출하는 자식 클래스를 정의할 수 있습니다.

객체 인스턴스 생성

// 클래스 생성자를 이용한 객체 인스턴스 생성
const 변수명 = new 클래스명(필드1값, 필드2값, 필드3값);

// 객체 정보 조회
console.log(변수명);

// 객체 필드 값 수정
변수명.필드명1 = 값;

// 객체 내 메서드 호출
변수명.메서드명();

타입스크립트 클래스를 이용하여 객체 인스턴스를 생성할 수 있습니다.

클래스 타입에 맞춘 일반 객체 정의

const 변수명: 클래스명 = {
  필드명1: 필드1값,
  필드명2: 필드2값,
  필드명3: 필드3값,
  메서드명() { }
}

클래스를 타입처럼 사용하여 객체를 정의할 수 있습니다.
인스턴스가 생성되는 문법은 아닙니다.

클래스 접근제어자

class 클래스명 {
  // 어디에서든 접근 가능 (기본값)
  public 필드명1;
  필드명2; // 접근제어자 생략 시 public

  // 현재 클래스에서만 접근 가능
  // 외부 및 자식 클래스에서 필드 접근 불가
  // 메서드를 통해서만 필드 접근 가능
  private 필드명3;

  // 현재 클래스 또는 자식 클래스에서만 필드 접근 가능
  // java와 달리, 같은 폴더/파일에서는 접근 불가
  protected 필드명4;

  public 메서드명() {
    // 클래스 내부에서는 private 필드 접근 가능
    return this.필드명3;
  }
}

타입스크립트 접근제어자 사용 예시입니다.
Javascript에는 접근제어자가 없고, 타입스크립트에서만 제공하는 기능입니다.

클래스 생성자에서 접근제어자 사용

class 클래스명 {
  constructor(public 필드명1: string, private 필드명2: number) {
    // 필드 값 초기화 로직 생략 가능
  }
}

위와 같이, 클래스 생성자에서 각 필드에 접근제어자 사용 시 필드를 정의하지 않아도 됩니다.
필드에 값을 할당하는 로직도 타입스크립트가 Javascript 파일로 컴파일 시 자동으로 생성해 줍니다.

인터페이스를 구현한 클래스 정의

// 인터페이스 정의
interface 인터페이스명 {
  필드명1: string; // 인터페이스에서는 public 필드만 정의 가능
  필드명2: number;
  메서드명(): void;
}

// 인터페이스를 구현한 클래스 정의
// 인터페이스 내 필드 및 메서드 미구현 시 오류 발생
class 클래스명 implements 인터페이스명 {
  필드명1: string;
  필드명2: number;
  private 필드명3: string;

  constructor(필드명1: string, 필드명2: number, 필드명3: string) {
    this.필드명1 = 필드명1;
    this.필드명2 = 필드명2;
    this.필드명3 = 필드명3;
  }

  메서드명(): void {
    console.log(`필드명1 : ${this.필드명1}`);
  }
}

인터페이스 정의 후, 인터페이스를 구현한 클래스를 정의할 수 있습니다.


제네릭

함수, 클래스, 인터페이스 등에서 전달받은 매개변수 타입을 기반으로 추론하는 문법입니다.
데이터 타입을 직접 명시하지 않아도, 다양한 타입의 데이터 처리가 가능합니다.

제네릭 사용 시, typeof 같은 타입 가드 없이도 타입이 추론되어 코드가 간결해질 수 있습니다.

제네릭 함수 정의

// 제네릭 타입 변수 정의 후 매개변수 타입, 반환값 타입 명시
function 함수명<T>(value: T): T {
  return value;
}

// 타입 명시적 사용
let str = 함수명<string>("hello");
let arr = 함수명<[number, number, number]>([1, 2, 3]);

// 타입 추론 사용
함수명(123);

제네릭 함수 호출 시 타입을 명시해도 되고, 명시하지 않아도 자동으로 추론됩니다.

제네릭 변수 타입이 여러 개인 경우

// T : 첫 번쨰 매개변수 a의 타입
// U : 두 번째 매개변수 b의 타입
function 함수명<T, U>(a: T, b: U) {
  return [b, a];
}

// 구조분해 할당으로 받기
const [b, a] = 함수명("문자", 1);

제네릭 변수 타입이 배열인 경우

// 매개변수 타입을 제네릭 배열 타입으로 정의
function 함수명<T>(data: T[]) {
  return data[0];
}

// number | string 유니온 타입으로 추론 됨
let num = 함수명([1, "문자"]);

매개변수 타입을 T[]로 정의하면 배열 내 매개변수 타입이 여러개인 경우 유니온 타입으로 추론됩니다.
아래와 같이, 튜플을 이용해서 첫 번째 요소 타입을 제네릭으로 정의하면 타입이 좁혀집니다.

// 매개변수 타입을 제네릭 배열 타입으로 정의
function 함수명<T>(data: [T, ...unknown[]]) {
  return data[0];
}

// number 타입으로 추론 됨
let num = 함수명([1, "문자"]);

특정 프로퍼티가 있는 매개변수만 받을 경우

// 특정 프로퍼티 (length) 를 가진 매개변수만 받도록 정의
function 함수명<T extends { length: number }>(data: T) {
  return data.length;
}

// length 프로퍼티를 가진 매개변수로 함수 호출
let num = 함수명([ 1, 2, 3 ]);
let num = 함수명("1,2,3,4,5");
let num = 함수명({ length: 10 });

// length 프로퍼티가 없는 매개변수는 함수 호출 불가 (타입 에러 발생)
let num = 함수명(10);

제네릭 함수 타입 정의 응용 (map 함수)

// map 함수 타입 정의 예시
function map<T, U>(arr: T[], callback: (item: T) => U): U[] {
  let result = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(callback(arr[i]));
  }

  return result;
}

// map 함수 사용 예시
let arr1 = map([1, 2, 3], (it) => it * 2);
let arr2 = map(["1", "2"], (it) => parseInt(it));
// arr : 매개변수 배열 타입으로 추론
// calback 함수 매개변수 : 매개변수 배열의 원소 타입으로 추론
// calback 함수 반환값 : calback 반환값 타입으로 추론
// map 함수 반환값 : calback 반환값 타입의 배열 타입으로 추론

제네릭 함수 타입 정의 방법을 응용하여 map 함수를 정의한 코드입니다.

제네릭 함수 반환값 타입 정의

// 인터페이스 정의
interface 인터페이스명 {
  id: number;
}

// 제네릭 클래스 정의
class 클래스명<T> {
  constructor(private id: T) {}

  getId(): T {
    return this.id;
  }
}

// 함수 반환값 타입 명시
function 함수명(): 클래스명<인터페이스명> {
  return new 클래스명({id: 123});
}

// 제네릭으로 추론된 객체 프로퍼티 접근 가능
const 객체명 = 함수명();
let num = 객체명.getId();

위와 같이, 함수 반환값을 제네릭 클래스로 정의하는 것이 가능합니다.

제네릭 인터페이스 정의

// 제네릭 인터페이스 선언
interface 인터페이스명<K, V> {
  key: K;
  value: V;
}

// 제네릭 인터페이스 타입 변수 선언
// 제네릭 타입 변수 값 지정 필수
let 변수명1: 인터페이스명<string, number> = {
  key: "문자",
  value: 0
}
let 변수명2: 인터페이스명<boolean, string[]> = {
  key: true,
  value: ["1"]
}

// 제네릭 인터페이스 타입 매개변수 사용 함수 선언
function 함수명(변수명: 인터페이스명<string, boolean>) {
  
}

하나의 제네릭 인터페이스로 다양한 타입 객체를 생성할 수 있습니다.

제네릭 인터페이스 정의 시 인덱스 시그니처 활용

// 제네릭 인터페이스 선언
interface 인터페이스명<V> {
  [key: string]: V; // 인덱스 시그니처 문법
}

// 제네릭 인터페이스 타입 변수 선언
let 변수명: 인터페이스명<string> = {
  key: "value"
}
let 변수명: 인터페이스명<number> = {
  key: 1
}

제네릭, 인덱스 시그니처로 키 타입이 string이면 값 타입은 어떤 타입이든 허용할 수 있습니다.

제네릭 타입 별칭 정의

// 제네릭 타입 별칭 선언
type 타입별칭명<V> = {
  [key: string]: V;
}

// 제네릭 타입 별칭으로 객체 선언
// 제네릭 타입 변수 값 지정 필수
let 변수명: 타입별칭명<string> = {
  key1: "value1",
  key2: "value2",
}

제네릭, 인덱스 시그니처로 다양한 값 타입을 지정할 수 있는 객체 타입 별칭을 정의합니다.

제네릭 클래스 정의

// 제네릭 클래스 선언
class 클래스명<T> {
  // 매개변수에 접근제어자 사용 시 매개변수 선언 및 값 할당 생략 가능
  constructor(private list: T[]) {}

  push(data: T) {
    this.list.push(data);
  }

  pop(): T | undefined {
    return this.list.pop();
  }

  print() {
    console.log(this.list);
  }
}

// 제네릭 클래스로 객체 생성
// 생성자 매개변수를 통해 타입 추론
const 변수명 = new 클래스명([1, 2, 3]); // 타입 추론 : 클래스명<number>
또는
const 변수명 = new 클래스명(["1", "2", "3"]); // 타입 추론 : 클래스명<string>
변수명.pop();
변수명.push(값);
변수명.print();

다양한 데이터 타입 매개변수를 받을 수 있는 제네릭 클래스를 생성할 수 있습니다.

제네릭 클래스로 정의된 Promise 객체로 API 호출 시 성공 결과 타입 설정

const promise = new Promise<number>((resolve, reject) => {
  // 실제 API 호출 가정하여, 3초 후 결과 호출
  setTimeout(() => {
    // API 성공 시 콜백 함수 호출 (파라미터 : 결과값)
    resolve(20);

    // API 실패 시 콜백 함수 호출 (파라미터 : 실패 이유)
    reject(new Error("API 호출 실패"));
  }, 3000);
});

// resolve 함수 호출 결과값 받기
promise
  .then((response) => {
    // resolve 함수 파라미터 출력
    console.log(response);
  })
  .catch((err) => {
    // reject 함수 파라미터 출력
    if (err instanceof Error) {
      console.error(err.message);
    }
  });

JavaScript 내장 클래스 promise 객체를 사용하여 비동기 요청 시,
제네릭 타입을 지정해서 resolve 함수 결과값 타입을 설정할 수 있습니다.