[TIL / DAY 28] 타입 별칭 / 상속 / 제너릭

miseullang·2024년 11월 20일
post-thumbnail

✅ 강의 내용 정리

📍 타입스크립트는 왜 사용할까?


런타임 기반의 자바스크립트 언어를 정적 기반의 언어로 지원함으로써 코드의 안정성을 높이기 위해 사용한다.
정적 언어의 기능을 자바스크립트에 추가하기 위해서 타입스크립트가 만들어졌다.

👻 복습


  1. 기본 자료형에 대한 타입 시스템
  2. 참조 자료형(객체, 배열, 함수)에 대한 타입 시스템
  3. any : 모든 타입을 허용
  4. unknown : any랑 비슷하지만, any는 아예 타입 지정이 없다면 unknown은 타입 지정을 값의 할당이 아닌 사용 상황으로 넘기는 것
  5. tuple : 고정된 수의 요소를 가진 배열의 타입을 지정할 때 사용하는 것
  6. 타입 표명 : 타입을 명시적으로 지정하는 방법 (let number:number = 10;)
  7. 타입 추론 : 할당된 값을 보고 자동으로 타입을 추론하는 방법 (let num = 10;)
  8. 유니언 타입 (|) : 타입의 OR 연산
  9. 인터섹션 타입 (&) : 타입의 AND 연산
  10. 타입 별칭 : 사용자 정의 타입 / type이라는 키워드로 선언한다
  11. 옵셔널 타입 연산자(?) : 해당하는 값이 있을 수도, 없을 수도 있을 때

읽기 전용


배열이나 튜플과 사용할 수 있는 키워드
readonly : 변수를 완전히 상수로 바꿈
자바스크립트의 freeze 등은 변수를 상수로 바꾸는 개념이 아니라 접근을 못하게 막는 거였지만, 타입스크립트는 상수로 완전히 변경이 가능하다.

📍 인덱스 시그니처


객체 타입이 중복될 때 적용할 수 있는 방법

처음 타입 지정 배울 때부터 (개발자들이 이렇게 수고롭게 코드를 작성할 것 같지 않아서) 찾아봤던 내용이었다.

원래라면 각 key마다 타입을 지정해줘야 했지만, 인덱스 시그니처를 사용하면 한번에 지정할 수 있다.
단, 값이 모두 같지 않을 때는 유니온 타입으로 여러 타입을 지정해줘야 해서, 타입이 대부분 같을 때만 사용한다.

const user: {
    [key: string]: string;
  } = {
    name: "John",
    gender: "male",
    address: "seoul",
  };

📍 타입 별칭 (Type Alias)


TypeScript에서 타입에 사용자 정의 이름을 부여할 수 있는 기능
type 키워드를 사용하여 새로운 타입 이름을 정의하며, 코드의 재사용성과 가독성을 높일 수 있다.

  1. 기본 타입 별칭
type ID = string | number;

const userId: ID = "user123";  // OK
const userId2: ID = 12345;     // OK
const userId3: ID = true;      // Error
  1. 객체 타입 별칭
type User = {
    name: string;
    age: number;
}

** 방식 비교

// 기존 방식
const user: {
    name: string;
    age: number;
} = {
    name: "John",
    age: 20,
};

// 타입 별칭 사용 - 더 깔끔하고 재사용 가능
const user2: User = {
    name: "John",
    age: 20,
};
  1. 함수 타입 별칭
// 기본 함수 정의
const sum = function(n1: number, n2: number): number {
    return n1 + n2;
};

// 함수 타입 별칭 사용
type SumFunction = (n1: number, n2: number) => number;

const sum2: SumFunction = function(n1, n2) {
    return n1 + n2;
};
  1. 튜플 타입 별칭
type Point = [number, number];
const point: Point = [10, 20];
  1. 인터섹션 타입 별칭
type Nameable = {
    name?: string;
};
type Ageable = {
    age?: number;
};
type Person = Nameable & Ageable & {
    gender?: string;
};
const person: Person = {};
  1. 리터럴 타입 별칭
type Direction = "LEFT" | "RIGHT" | "UP" | "DOWN";
const direction: Direction = "RIGHT";
  1. 조건부 타입 별칭
type IsString<T> = T extends string ? "YES" : "NO";
const test1: IsString<string> = "YES";
const test2: IsString<number> = "NO";
  1. 키 선택 타입 별칭

키만 뽑아서 유니언 타입으로 묶어줄 수 있다

type Persons = {
    name: string;
    age: number;
    address: string;
};
type PersonKeys = keyof Persons; // "name" | "age" | "address"
const key: PersonKeys = "address";
  1. 인덱스 시그니처 타입 별칭

객체의 속성을 동적으로 정할 수 있다.
키 이름을 명확하게 적시한 게 아니기 때문에 인덱스 시그니처로 타입을 지정해준 경우 자동 완성이 되지 않는다.

type UserMap = {
    [key: string]: string;
};
let users: UserMap = {
    name: "John",
    gender: "male",
    address: "seoul",
};

타입 별칭은 왜 사용할까?

  1. 코드 재사용성 향상
  2. 타입 정의의 중복 제거
  3. 더 명확한 코드 가독성 (다양한 타입을 나열하는 경우 가독성 저하)
  4. 복잡한 타입을 간단한 이름으로 참조 가능
  5. 유지보수 용이성 증가

⇒ 자주 사용되는 타입 패턴을 별칭으로 정의하면 코드가 더 깔끔해지고 관리하기 쉬워지기 때문

📍 제네릭


  • 제네릭 개념

    제네릭

    콘크리트 타입
    number, string, unknown ...

    제네릭 타입
    타입의 placeholder같은 것

    제네릭이란?
    선언 시점이 아니라 생성 시점에 타입을 명시하여 하나의 타입만이 아닌 다양한 타입을 사용할 수 있도록 하는 기법

    왜 제네릭을 사용하나? 콜 시그니처를 작성할 때, 내용으로 들어올 타입을 확실히 모를 때 사용
    type SuperPrint = {
        <TypePlaceholder>(arr: TypePlaceholder[]): void;
        // <제네릭명>(arr: 제네릭명[]): void;
      };
    
      const superPrint: SuperPrint = (arr) => {
        arr.forEach((i) => {
          console.log(i);
        });
      };
    
      superPrint([1, 2, 3, 4]);
      superPrint([true, false, true]);
      superPrint(["a", "b", "c"]); // 제네릭 타입을 사용하면 해결
      }
    ** 제네릭과 any의 차이 'any'를 사용하는 것은 어떤 타입이든 받을 수 있다는 점에서 'generics'과 같지만 함수를 반환하는데 있어 'any'는 받았던 인수들의 타입을 활용하지 못한다 즉, 'generics'은 어떤 타입이든 받을 수 있다는 점에서 'any'와 같지만 해당 정보를 잃지 않고 타입에 대한 정보를 다른 쪽으로 전달할 수 있다는 점이 다르다 any 타입을 사용하면 아래 항목의 콜 시그니처가 모두 any로 뜨지만, 제네릭을 사용하면 각각 string[], boolean[] … 이 된다.

    제네릭 확장 개념
    타입을 생성하고 그 타입을 또다른 타입에 넣어서 사용할 수 있다.

    코드 설명 : Player라는 타입에 라는 제네릭 타입을 사용해서 정의해주고 NicoExtra를 정의한 뒤
    NicoPlayer에서 그 두 개를 합쳐줌
    {
      // 제네릭 확장 개념
      type Player<E> = {
        name: string;
        extraInfo: E;
      };
    
      type NicoExtra = {
        favFood: string;
      };
    
      type NicoPlayer = Player<NicoExtra>;
    
      const nico: NicoPlayer = {
        name: "nico",
        extraInfo: {
          favFood: "김치",
        },
      };
    }
    

제네릭

제네릭은 대문자로 작명하는 관습이 있고, Type을 따서 라고 짓는 경우가 많다.

  type Car<T> = {
    name: string;
    color: string;
    option: T;
  };

  const car: Car<null> = {
    name: "Benz",
    color: "black",
    option: null,
  };

  // 제네릭 (관용적으로 대문자로 지정)
  // 치환
  // 코드의 재사용성 증가
  const car3: Car<{ giftcard: boolean }> = {
    name: "Benz",
    color: "black",
    option: {
      giftcard: true,
    },
  };

이제 정리해야 할 내용

📍 인터페이스와 읽기 전용


readonly -> 객체 타입에서 사용가능한 문법
튜플이나 배열에서는 변수를 상수화(읽기 전용)하고, 객체에서는 속성을 상수화(읽기 전용) 한다.

  // 인터페이스
  // 객체 타입을 지정할 때 사용하는 문법

  // 1. 기본 형식
  interface User {
    name: string;
    age?: number;
  }

  const user: User = {
    name: "John",
  };

  // 2. 옵셔널 형상
  {
    interface User {
      name: string;
      age?: number;
    }
    const user: User = {
      name: "John",
    };
  }
  // 3. readonly -> 튜플이나 배열 : 변수를 상수(읽기전용), 객체 -> 속성 상수(읽기전용)
  {
    interface User {
      name: string;
      readonly age: number;
    }
    const user: User = {
      name: "John",
      age: 20,
    };
    // user.age = 20; 에러발생함
  }
  // 4.인덱스 시그니쳐 -> 객체 속성을 동적으로 추가할 수 있게됨
  {
    interface User {
      name: string; // 고정속성 -> 자주 사용하는 것들
      [key: string]: string; //이렇게 지정하면 VS code 의 자동완성 기능을 사용할 수 없음
    }
    const user: User = {
      name: "John",
      gender: "male",
    };
    user.name;
  }

  // 5. 함수 타입
  type SumFunc = (a: number, b: number) => number;
  const sum: SumFunc = (a, b) => a + b;

  // 인터페이스로 함수 타입을 지정할 수도 있지만, 타입 별칭에 비해 불편하므로 잘 사용하진 않는다.
  interface ISumFunc {
    (a: number, b: number): number;
    name: string; // 에러가 발생하지 않음 => 함수 자체가 갖는 이름을 가리킨다. 즉, 함수는 일급객체이므로 인터페이스로 함수 자체의 타입을 지정할 수 있음
  }
  const sum2: ISumFunc = (a, b) => a + b;
}

📍 객체의 타입 지정


주로 객체의 타입 지정은 Interface로, 그 외의 커스텀 타입 지정은 type로 한다.

많은 예제 코드에서 interface로 객체의 타입만을 다루기 때문에 오해하는 사람이 많지만, 함수도 interface를 사용해서 타입을 지정할 수 있다.

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

인터페이스는 자동 병합(선언 병합/Declaration Merging)이 이루어진다.

{
  // 타입 별칭과 인터페이스의 차이점
  // 1. 인터페이스는 자동 병합이 됨 => 선언 병합(Declaration Merging) : 따로 써도 합쳐진다
  interface User {
    name: string;
  }

  // 선언 병합은 선언 위치에 구애받지 않기 때문에 인터페이스 선언 사이에 인터페이스를 사용하더라도
  // 다음과 같이 오류가 발생함
  // => 이유 : 타입스크립트는 정적 타입 언어이기 때문
  // => 발생할 수 있는 오류 : 서로 다른 개발자가 각자 작업을 하고 한 스코프 내에서 두 파일을 호출했을 때
  // 같은 이름으로 인터페이스를 선언하면 선언 병합이 일어남
  // 변수에 선언되어 있는 타입을 확인하고자 할 때 type 별칭은 hover하면 확인이 가능하고, interface는 확인이 불가함
  // => 여러모로 인터페이스는 불편...

  const user1: User = {
    name: "John",
  };

  interface User {
    age: number;
  }
  // 2. 타입 별칭은 똑같은 식별자를 사용할 수 없음 => 에러 발생
  type TUser = {
    name: string;
  };
  type TUser = {
    age: number;
  };
}

인터페이스의 상속

  1. 기본 상속
interface Shape {
    color: string;
}

// Shape의 속성을 물려받으면서 추가 속성을 정의
interface Circle extends Shape {
    radius: number;
}

// 상속
interface Animal {
  name: string;
  sound: () => void; // ===  sound() : void 같은 내용을 두 가지로 사용할 수 있음
}

interface Pet extends Animal {
  name: string;
  play: () => void; // ===  sound() : void 같은 내용을 두 가지로 사용할 수 있음
  // play?() : void; // 옵셔널 연산자는 이렇게 표시
}
  • extends 키워드를 사용하여 기존 인터페이스의 속성을 상속받음
  • 자식 인터페이스는 부모의 모든 속성을 포함하면서 새로운 속성을 추가 정의할 수 있음
  1. 다중 상속
interface Person {
    name: string;
    age: number;
}

interface Address {
    city: string;
    zipcode: string;
}

// 여러 인터페이스를 동시에 상속
interface Employee extends Person, Address {
    employeeID: string;
}
  • 콤마(,)를 사용하여 여러 인터페이스를 동시에 상속 가능
  • 모든 부모 인터페이스의 속성들을 포함해야 함
  1. 메서드 정의와 상속
interface Animal {
    name: string;
    sound: () => void;  // 또는 sound(): void
}

interface Pet extends Animal {
    play: () => void;   // 또는 play(): void
}
  • 메서드도 상속되며, 두 가지 문법으로 정의 가능
  • 옵셔널 메서드는 ? 연산자로 정의 (play?(): void)

상속의 핵심 특징

  • 코드 재사용성 향상
  • 계층적 타입 구조 생성 가능
  • 다중 상속 지원
  • 부모 인터페이스의 모든 속성과 메서드를 반드시 구현해야 함

타입 별칭으로 상속 구현하기

타입 별칭은 원래 상속 개념이 없지만 인터페이스를 이용해서 비슷하게 만들 수 있다.

다음 예제에서 알 수 있듯 타입 별칭에 인터페이스를 섞어 넣는 방식이다.


  interface Shape {
    color: string;
  }
  
  type Shape = {
    color: string;
  };

  type Circle = Shape & {
    radius: number;
  };

  const circle: Circle = {
    radius: 10,
    color: "red",
  };

인터페이스를 아예 사용하지 않고도 인터섹션 타입(&)을 사용해 상속처럼 구현할 수 있다.

type Shape = {
    color: string;
};

type Circle = Shape & {
    radius: number;
};

const circle: Circle = {
    color: "red",
    radius: 10
};

고급 패턴 : 제네릭을 이용한 상속

강사님께서 고급 패턴이라고 알려주신 제네릭을 이용한 상속 패턴이다.

바로 이해를 하지 못해서 팀 활동 시간에 팀원들과 이야기도 나눠보고, 강사님 조언도 구해봤는데, 실무에서 적용하는 사례도 많지 않은 편이고, 굳이 시간을 들여 깊게 파고들 정도의 중요도는 아니라고 하셔서 이런 게 있구나 하는 정도로만 이해하고 넘어간다.

{
  // 제네릭을 이용한 상속
  // * 고급 적용 패턴 * 눈에 익히기
  interface Container<T> {
    value : T;
  }

  interface Box<T, U> extends Container<T> {
    label : string;
    scale?: T;
    inStock?: U;
  }

  const container : Container<number> = {
    value : 10
  }

  const box:Box<number, boolean> = {
    label : "grid",
    value : 10,
    scale : 10,
  }
}

💭 회고

profile
괴발개발 💻

0개의 댓글