[엘리스 sw 엔지니어 트랙] 25일차 interface, generic

오경찬·2022년 5월 17일
0

수업 25일차

타입스크립트의 마지막날이다. 이번주에 배우기 시작했는데 금방 끝나버렸다... 그런데 양은 엄청나다...???

이론

  • interface: 변수, 함수, 클래스에 타입 체크를 위해 사용, 코드안에 계약을 정의하기 위함
    properties: 컴파일러는 프로퍼티의 두가지 요소를 검사한다, 필수요소 프로퍼티의 유무,프로퍼티 타입
    optional Properties: 프로퍼티 선언시 이름 끝에 ?를 붙여서 사용, 사용가능한 프로퍼티를 기술할때 사용
    readonly properties: 객체가 서음 생성될 때만 값 설정 가능, 수정 불가능
    readonly와 const의 공통점 : 생성 후에 배열을 변경하지 않음을 보장
    interface types: ts에서는 함수, 클래스에서 사용가능
    function type: 함수의 인자의 타입과 반환 값의 타입을 정의
    class type: 클래스가 특정 계약을 충족하도록 명시적으로 강제함
    interface확장: 클래스와 마찬가지로 인터페이스도 인터페이스 간의 확장 가능
    hybrid type: 유연하고 동적인 타입 특성에 따라 여러가지 타입을 조합할수 있음
    전략패턴: 객체가 할수 있는 행위들 각가 전략으로 만들어 놓고, 동적으로 행위의 수정이 필요한 경우 전략을 바꾸는 것만으로 수정이 가능한것
    generic: 데이터 타입을 일관화함
    generic 사용이유: 재사용성이 높은 함수와 클래스를 생성할 수 있음, 오류를 쉽게 포착할수 있다.
    Union type: |을 사용해 두개 이상의 타입을 선언하는 방식
    constraints: 특정 타입들로만 동작하는 generic 함수를 만들때 사용
    keyof: 두 객체를 비교할 때 사용
    디자인 패턴: 객체를 생선하는 인터페이스만 미리 정의하고, 인스턴스를 만들 클래스의 결정은 서브 클래스가 내리는 패턴

Interface

function printLabel(labelledObj: { label: string }) {
  console.log(labelledObj.label);
}

let myObj = { size: 10, label: "Size 10 Object" };
printLabel(myObj);

위 코드에서 타입체커는 printLabel에 대한 호출을 확인합니다.
printLabel 함수는 String 타입의 값을 가지는 label이란 프로퍼티를 가진 객체를 인자로 받습니다.

컴파일러는 '최소한' 요구 조건에 맞는지 체크합니다.
즉, printLabel 함수의 인자는 객체이며 '최소한' String 타입의 값을 가지는 label이란 프로퍼티를 가진다.
라는 의미입니다.

예를 들어, 위 코드에서 printLabel함수는 myObj라는 객체를 인자로 받으며 호출되었습니다.
myObj 객체는 Number 타입의 값을 가지는 size 프로퍼티, 그리고 String 타입의 값을 가지는 label 프로퍼티로 이루어져있습니다.

label 프로퍼티가 존재하고 String 타입의 값을 가졌기 때문에, 객체의 다른 키와 밸류 값에 상관없이 조건을 만족합니다.

위의 코드는 아래와 같이 재작성할 수 있습니다.

interface LabelledValue {
  label: string;
}

function printLabel(labelledObj: LabelledValue) {
  console.log(labelledObj.label);
}

let myObj = { size: 10, label: "Size 10 Object" };
printLabel(myObj);

이런 식으로 printLabel이 객체로 받는 인자에 필요한 프로퍼티들을 모아서 하나의 이름으로 선언할 수 있습니다.

프로퍼티는 순서에 관계가 없으며 필요한 속성과 타입만을 체크합니다.

Optional Properties

인터페이스에는, 경우에 따라 프로퍼티가 필요할 수도 아닐수도 있습니다.
이 경우에, Optional 한 프로퍼티를 선언하여 해결할 수 있습니다.

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  let newSquare = { color: "white", area: 100 };
  if (config.color) {
    newSquare.color = config.color;
  }
  if (config.width) {
    newSquare.area = config.width * config.width;
  }
  return newSquare;
}

let mySquare = createSquare({ color: "black" });

Readonly Properties

읽기전용 프로퍼티를 선언할 수 있습니다.

interface Point {
  readonly x: number;
  readonly y: number;
}

let p1: Point = { x: 10, y: 20};
p1.x = 5; // 에러

Readonly Array

읽기전용 배열을 선언할 수 있습니다.

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;
ro[0] = 12; // 에러
ro.push(5); // 에러
ro.length = 100; // 에러
a = ro; // 에러

a = ro; 읽기전용 배열을 일반 배열로 재할당하는 것 조차도 금지됩니다.
하지만 type assertion을 통해 오버라이드 할 수 있습니다.

a = ro as number[];

readonly vs const

변수는 const, 프로퍼티는 readonly를 사용합니다.

Excess Property Checks

인자의 타입으로 지정 된 인터페이스에 없는 프로퍼티가 있을 경우 오류가 발생합니다.

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  // ...
}

// colour은 SquareConfig 타입에 필요하지 않습니다.
let mySquare = createSquare({ colour: "red", width: 100 });

검사하는 가장 쉬운 방법은 type assertion을 사용하는 것 입니다.

let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

SquarConfig가 추가로 프로퍼티를 가지는 경우 다음과 같이 정의할 수 있습니다.

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: any;

이렇게 작성을 했을 경우, color, width가 아닌 프로퍼티들의 타입은 상관이 없습니다.

let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

위와 같은 방식으로 작성을 하였을 때, 프로퍼티 초과 검사를 하지 않으므로 오류가 발생하지 않습니다만, 이러한 검사를 '회피하는' 코딩은 하지 말아야 합니다.

Functin Types

인터페이스는 프로퍼티를 가진 객체 이외에, 함수 타입을 선언하는데 이용될 수도 있습니다.

interface SearchFunc {
  (source: string, subString: string): boolean;
}

매개 변수 목록과, 반환 타입만 주어진 함수 선언과 같은 형태로 작성합니다.

아래와 같은 형태로 함수 인터페이스를 사용합니다.

let mySearch: SearchFunc;
mySearch = function(source: string, subString: string) {
  let result = source.search(subString);
  return result > -1;
};

매개 변수의 이름이 일치할 필요는 없습니다.

let mySearch: SearchFunc;
mySearch = function(src: string, sub: string): boolean {
  let result = src.search(sub);
  return result > -1;
};

리턴 값이 숫자 혹은 문자열 등 다른 타입인 경우 경고가 출력됩니다.

Indexable Types

인터페이스는 a[10] 또는 ageMap["daniel"] 처럼 '인덱스'를 생성할 수 있는 타입을 만들 수도 있습니다.

interface StringArray {
  [index: number]: string; // index signature
}

let myArray: StringArray;
myArray = ["Bob", "Fred"];

let myStr: string = myArray[0];

위의 인덱스 시그니처는 StringArray는 number로 인덱싱 될 때 string을 반환한다는 의미입니다.

타입스크립트에서 지원되는 인덱스 시그니처에는 문자열과 숫자 두 종류가 있습니다.

두 가지 모두를 지원하지만,
숫자 인덱서의 반환 타입은, 문자열 인덱서의 반환 타입의 하위 타입이어야합니다.

설명이 좀 어렵네요. 코드로 한번 보도록 할게요.

class Animal {
  name: string;
}
class Dog extends Animal {
  breed: string;
}

// 오류: numeric과 string으로 인덱싱하면 완전히 다른 타입의 Animal을 얻을 수 있습니다!
interface NotOkay {
  [x: number]: Animal;
  [x: string]: Dog;
}

number로 인덱싱이 지원된다 하더라도 결국에 내부적으로 a[100]은 a['100']과 동일하므로 더 넓은 범위를 커버하는 스트링이 반환값에서도 넓은 범위의 조건이 되어야 한다는 것입니다.

Animal이 Dog에게 상속하므로, Animal이 더 상위 타입이라고 판단할 수 있고 string인덱서의 반환 타입이 Dog인 것이 조건에 부합하지 않기 때문에 에러를 발생시키게 됩니다.

문자열 인덱스 시그니처는 모든 프로퍼티가 반환타입과 일치하도록 강제합니다.
obj.property는 obj["property"]와 같은 의미로 사용되기 때문입니다.

즉, 아래와 같은 코드에서는 오류를 발생시키게됩니다(모든 프로퍼티의 타입은 시그니처의 반환타입 같은 타입이 되어야함).

interface NumberDictionary {
  [index: string]: number;
  length: number; // 좋아요, length는 number입니다.
  name: string; // 오류, 'name'의 타입이 인덱서의 하위 타입이 아닙니다.
}

readonly를 사용하여 인덱스 시그니처를 읽기 전용으로 만들 수 있습니다.

interface ReadonlyStringArray {
  readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // 오류!

배열과 비슷하게 보이지만, 배열에서 사용가능한 어떤 메소드도 적용할 수 없습니다.

Class Types

C#, Java 등의 언어에서 인터페이스의 가장 일반적인 용도는 클래스 제약조건 명시입니다.

interface ClockInterface {
  currenrTime: Date;
}

class Clock implements ClockInterface {
  currentTime: Date;
  constructor(h: number, m: number) {}
}

또한, 메서드의 매개변수, 반환타입 등도 명시 할 수 있습니다.

interface ClockInterface {
  currentTime: Date;
  setTime(d: Date); // => setTime(d: Date): void;
}

class Clock implements ClockInterface {
  currentTime: Date;
  setTime(d: Date) {
    this.currentTime = d;
  }
  constructor(h: number, m: number) {}
}

위의 코드는 interface의 setTime선언 부분에서 경고를 출력합니다(출처는 공식문서...).

메서드의 반환타입이 명시되어있지 않기때문입니다. 묵시적으로 any로 지정되지만, 타입스크립트를 사용하는 이상 타입을 지정해주는 것이 더 좋다고 판단됩니다.

인터페이스는 public, private 양쪽이아닌 public 클래스만 만듭니다.
클래스를 사용하여 클래스 인스턴스의 private측에 특정 타입이 있는지 검사하는 것은 금지되어 있습니다.

Difference between the static and instance sides of classes

클래스와 인터페이스로 작업할 때 클래스에 두 가지 타입이 있음을 기억해야합니다.
'static, instance 타입'
construct signature 로 인터페이스를 만들고 이 인터페이스를 구현하는 클래스를 생성하려고 하면 오류가 발생할 수 있습니다.

interface ClockConstructor {
  new (hour: number, minute: number);
}

class Clock implements ClockConstructor {
  currentTime: Date;
  constructor(h: number, m: number) {}
}

클래스가 인터페이스를 구현할 때, 클래스의 인스턴스 측면만 검사되기 때문입니다.
생성자는 정적인 측면에 속합니다.

이 문제를 해결하려면 클래스의 정적인 측면에서 검사해야합니다.

interface ClockConstructor {
  new (hour: number, minute: number): ClockInterface;
  
}
interface ClockInterface {
  tick();
}

function createClock(
  ctor: ClockConstructor,
  hour: number,
  minute: number
): ClockInterface {
  return new ctor(hour, minute);
}

class DigitalClock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
    console.log("beep beep");
  }
}
class AnalogClock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
    console.log("tick tock");
  }
}

let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

일반적인 클래스를 통한 인터페이스 구현과 반대로 이루어집니다.

아래 코드는 흔히 사용하는 클래스를 통한 인터페이스 구현의 예제입니다.

interface I_FileManager {
  send(fileName: string);
  receive(fileName: string);
}

class FileManager implements I_FileManger{
  contructor(){}
  send(fileName: string){
    // ...
  }
  receive(fileName: string){
    // ...
  }
}

const fileManager = new FileManager();
fileManager.send('1.jpg'); 

Extending Interfaces

클래스처럼 인터페이스도 확장이 가능합니다.
예제로 바로 보시면 이해가 빠르실겁니다.

interface Shape {
  color: string;
}

interface Square extends Shape {
  sideLength: number;
}

let square = {} as Square; // <Square>{}; 같은 표현입니다.
square.color = "blue";

여러 인터페이스를 결합할 수 있습니다.

interface Shape {
  color: string;
}

interface PenStroke {
  penWidth: number;
}

interface Square extends Shape, PenStroke {
  sideLength: number;
}

let square = <Square>{};
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;

Hybrid Types

메서드와 프로퍼티 모두를 가진 하이브리드 타입의 인터페이스도 있습니다.

interface Counter {
  (start: number): string;
  interval: number;
  reset(): void;
}

function getCounter(): Counter {
  let counter = <Counter>function(start: number) {};
  counter.interval = 123;
  counter.reset = function() {};
  return counter;
}

let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

Interfaces Extending Classes

인터페이스가 클래스를 확장하면 멤버를 상속하지만 구현은 상속하지 않습니다. 이 패턴은 private 및 protected 멤버도 상속합니다.

private 또는 protected 멤버가 있는 클래스를 확장하는 인터페이스는 해당 클래스 또는 해당 클래스의 서브 클래스에서만 구현 가능합니다.

예시)

class Control {
  private state: any;
}

interface SelectableControl extends Control {
  select(): void;
}

class Button extends Control implements SelectableControl {
  select() {}
}

class TextBox extends Control {
  select() {}
}

// 오류: 'Image' 타입의 'state' 프로퍼티가 없습니다.
class Image implements SelectableControl {
  select() {}
}

class Location {}

위 코드에서 SelectableControl에는 private state 프로퍼티를 포함한 Control의 모든 멤버가 포함되어있습니다.

state는 private 멤버이기 때문에 Control의 자식만 SelectableControl을 구현할 수 있습니다.

private 멤버는 해당 클래스, 혹은 해당 클래스의 서브 클래스에만 존재합니다.

SelectableControl은 select메서드를 가진 Control과 같은 역할을 합니다.
Button과 TextBox클래스는 둘다 Control을 상속받고 select메서드를 가졌기 때문에 SelectableControl의 하위 타입입니다.

Image는 Control을 상속하지 않았기 때문에 state 프로퍼티가 존재하지 않습니다.

Generic

사실 Generic은 java와 같은 다른 언어에서 이미 사용되어 온 특징이다.

Generic은 재사용을 목적으로 함수나 클래스의 선언 시점이 아닌, 사용(호출) 시점에 타입을 선언할 수 있게 됩니다.

제네릭을 사용하게 되면 다음의 장점을 누릴 수 있습니다.

코드를 입력하세요

(1) 재사용하므로 타입 정의 코드를 줄여나갈 수 있다.

(2) 재사용을 위해 any 범벅을 만들지 않아도 된다. any는 헬퍼의 도움을 받을 수 없지만 제네릭은 타입을 부여해주므로 헬퍼가 작동하므로 generic을 쓰는 것이 훨씬 좋다.

// generic사용. 재사용 가능하면 타이핑도 들어가 자동 완성 해 줌
function logText<T>(param: T): T {
  return param;
}

// any 사용. 재사용은 할 수 있으나 타이핑이 안 됨
function logText(param: any): any {
  return param;
}

꼭 함수뿐만 아니라 인터페이스, 클래스, 함수, 타입 별칭 등에 사용할 수 있습니다.

<> 안의 문자는 꼭 T가 아니어도 됩니다. Type의 줄임말로 관습적으로 T를 사용할 뿐입니다.

// 함수에서 사용
function identity<T>(arg: T): T {
  return arg;
}

// interface에서 사용
interface IValue<T> {
  value: T;
}

// 클래스에서 사용
class Valuable<T> {
  constructor(public value: T) {}
}

generic에 호출 시점에서의 타입 명시
함수를 정의하는 부분에서는 제네릭을 통해 타입을 선언하지 않았지만 <> 내에 특정 타입을 지정함으로써 타입을 지정할 수 있습니다. 보통 이 방식을 많이 사용하죠. 물론 지정하지 않아도 타입을 추론해서 적절한 제레닉 타입을 찾아냅니다.

function identity<T>(arg: T): T {
return arg;
}
// 타입 지정
console.log(identity<number>(3));
console.log(identity<string>("coding"));

// 타입 추론 해 줌
console.log(identity([1, 3, 5]));

리턴하는 타입이 명시된다는 것이 왜 중요하냐면, return된 값으로 활용할 때 헬퍼가 작동하기 때문입니다.

interface, class에 generic 사용

클래스나 인터페이스도 마찬가지로 제네릭을 활용할 수 있으므로 다음과 같이 작성할 수 있습니다.

interface DropDown<T> {
  value: T;
  selected: boolean;
}

const obj: DropDown<number> = {
  value: 1,
  selected: false,
};
class Human<T, K> {
constructor(private _name: T, public age: K) {}
say(): void {
  console.log(`${this._name} is ${this.age} years old`);
}
}

const me = new Human<string, number>("foo", 100);
console.log(me);

Generic 타입 제한

앞서 아래와 같이 Generic에 extends {toString: Function}을 붙여서 특정 메서드를 가진 타입으로만 가능하도록 제한한 부분이 있습니다.

toString 메서드를 가진 타입만 사용하도록 만들 수 있습니다.

function createDropdownItem<T extends { toString: Function }> ... 중략

비슷하게, length 메서드가 있는 타입만 지정할 수 있도록 generic을 제한하는 방법은 다음과 같습니다.

아래 예시는 length 메서드가 있는 타입으로 제한하였습니다.

function logTextLenghth<T extends { length: number }>(text: T): number {
  return text.length;
}

// 잘 안보이면 interface로 분리해보자
interface LengthType {
  length: number;
}

function logTextLenghth<T extends LengthType>(text: T): number {
  return text.length;
}

추가로, keyof를 사용하여 특정 객체의 키값만을 T로 넣을 수 있게할 수 있습니다.

아래 예시는 itemOption에는 name, price, stock만 올 수 있습니다. keyof에 대해 알고 계시면 이해할 수 있습니다.

interface ShoppingItem {
  name: string;
  price: number;
  stock: number;
}

// ShoppingItem의 키 중에서 한가지가 Generic이 된다.
function getShoppingItemOption<T extends keyof ShoppingItem>(itemOption: T): T {
  return itemOption;
}

console.log(getShoppingItemOption("name"));
console.log(getShoppingItemOption("price"));
console.log(getShoppingItemOption("stock"));

typescript에서 js querySelector를 정의한 부분을 사렾보신다면 keyof를 요긴하게 사용한 것을 확인하실 수 있습니다.

profile
코린이 입니당 :)

0개의 댓글