header wave

Post

TS) 데코레이터와 심화 타입

2022-09-29 AM 06/06
#typescript
#study

데코레이터

데코레이터는 클래스 선언, 메서드 접근자, 프로퍼티, 파라미터에 추가하는 특별한 선언이다.

데코레이터는 선언에 대한 정보와 함께 런타임에 호출되는 함수 이름 앞에 @를 붙인 형식을 사용한다.

@Injectable() class A {}

@Injectable() 데코레이터가 클래스 A의 동작을 변경시키리라 예상할 수 있다.

코드를 수정하지 않고 클래스 A의 동작을 변경할 수 있는 다른 방법은 class A에 서브 클래스를 생성하고 동작을 추가하거나 다시 작성할 수도 있다 (but, 코드가 길어진다.)

클래스에 데코레이터를 추가하는 것이 더 좋은 방법이다. (간단)

데코레이터는 클래스 A인 특정 대상에 메타데이터를 추가할 수도 있다.

직접 데코레이터를 만들 수 있지만, 일반적으로 라비르러리나 프레임워크에서 제공되는 데코레이터를 사용하는 일이 더 많다.

class OrderComponent{
	quantity: number;
}

위 클래스를 UI 컴포넌트로 바꾸고 부모 컴포넌트에서 quantity 프로퍼티 값을 가져오는 경우 Angular와 타입스크립트를 사용한다면 아래처럼

@Component ({ // @Component 데코레이터 적용
	selector: 'order-processor', // 이 컴포넌트는 <order-processor>로 HTML에 사용된다.
	template: 'Buying {{quantity}} items', // 해당 텍스트를 렌더링한다.
});

export class OrderComponent {
	@Input() quantity: number; // 입력 프로퍼티 값은 부모에서 잔달받는다.
}

타입스크립트는 내장 데코레이터를 제공하지 않지만, 직접 데코레이터를 만들거나 프레임워크 또는 라이브러리 내 데코레이터를 사용할 수 있다.

데코레이터는 2015년에 도입되었지만 여전히 실험적인 기능으로 프로젝트에 사용하려면 아래 옵션이 필요하다.

"experimentalDecorators": true

클래스 데코레이터 생성

함수와 같이 클래스도 데코레이터를 만들 수 있다.

클래스 생성자가 실행될 때마다 데코레이터 함수가 실행된다.

// 커스텀 데코레이터
function whoAmI(target: Function): void {
	console.log(`You are \n ${target}`)
}

// whoAmI 데코레이터를 클래스에 추가
@whoAmI
class Friend {
	constructor(private name: string, private age: number){}
}

// in console

[LOG]: "You are: 
 class Friend {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
}"

function UIcomponent (html: string): Function {
   console.log(`The decorator received ${html} \n`);

   return function(target: Function) {
      console.log(`Someone wants to create a UI component from \n ${target} `);
   }
}

@UIcomponent('<h1>Hello Shopper!</h1>')
class Shopper {
  constructor(private name: string) {}
}

UIcomponent() 는 데코레이터가 아니라 function(target:Function) 시그니처를 가진 실제 데코레이터를 반환하는 데코레이터 팩토리다!

생성자 믹스인을 통한 클래스 선언 수정

믹스인 : 특정 동작을 구현하는 클래스, 단독으로 사용할 수 없으며 다른 클래스에 추가하여 사용하는 것

만약 믹스인에 생성자가 없다면, 코드를 다른 클래스와 섞어 쓰게 되어 프로퍼티와 메서드가 타켓 클래스로 들어간다.

그러나 믹스인에 생성자가 있는 경우, 타입 파라미터를 여러개 사용할 수 있어 타겟 클래스의 임의 생성자와 섞이지 않는다.

// 타입스크립트는 아래와 같은 시그니처를 가진 생성자 믹스인을 제공

{new(..args:any[]):{}}

// use case

type constructorMixin = {new(..args:any[]):{}}


// 아래 시그니처는 constructorMixin을 확장하는 제네릭 타입 T를 나타낸다.
// 타입스크립트에서 타입 T는 constructorMixin에 할당 가능함을 의미한다.
<T extends constructorMixin>

type constructorMixin = { new(...args: any[]): {} };

function useSalutation(salutation: string):Function {

    return function <T extends constructorMixin> (target: T) {
        return class extends target {
            name: string;
            private message = 'Hello ' + salutation + this.name;
          
            sayHello() { console.log(`${this.message}`); }          
        }
    }
}

@useSalutation("Mr. ")
class Greeter {

    constructor(public name: string) { }

    sayHello() { console.log(`Hello ${this.name}`) };
}

const grt = new Greeter('Smith');
grt.sayHello();

클래스 선언을 대체하는 강력한 메커니즘이지만 주의해서 사용해야 한다.

정적 타입 분석기는 데코레이터가 추가된 public 프로퍼티나 메서드를 대상으로 자동 완성 기능을 제공하지 않기 때문에 클래스 public API를 변경하지 않아야 한다.

useSalutation() 함수에서 public인 sayGoodBye()를 추가한다면 아래처럼 된다.

image

sayGoodBye() 메서드가 자동완성 되지 않는다.

하지만, 작동은 잘 된다!!

데코레이터 시그니처의 공식적인 선언 방법

데코레이터는 함수이며 시그니처는 타겟에 따라 다르다.

클래스 데코레이터와 메서드 데코레이터 시그니처는 동일하지 않다. 타입스크립트를 설치하면 타입 선언이 포함된 여러 파일도 들어있다.

lib.es5.d.ts


// 클래스 데코레이터 시그니처

declare type ClassDecorator = 
	<TFunction extends Function>(target: TFunction) => TFunction | void;

// 프로퍼티 데코레이터 시그니처

declare type PropertyDecorator = 
	(target: Object, propertyKey: string | symbol) => void;

// 메서드 데코레이터 시그니처

declare type MethodDecorator =
	<T>(target: Object, propertyKey: string | symbol,
	descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;

// 파라미터 데코레이터 시그니처

declare type ParameterDecorator =
	(target: Object, propertyKey: string | symbol, parameterIndex: number) => void;

<T>(someParam: T) => T | void

화살표 함수 T는 T타입 혹은 반환 값이 없는 함수 시그니처

	<TFunction extends Function>(target: TFunction) => TFunction | void;

화살표 함수는 제네릭 타입 TFunction인 파라미터를 가질 수 있다.

상세 타입은 Function의 하위 타입이여야 한다.

모든 타입클래스 클래스는 생성자 함수를 가리키는 Function의 서브타입이다.

데코레이터 타겟은 클래스여야만 하고, 데코레이터는 클래스 타입 값 또는 반환 값을 반환한다.

메서드 데코레이터

곧 삭제될 메서드를 표시하는 @deprecated 데코레이터는 세 가지 매개변수가 필요하다.

  • target: 메서드 클래스를 참조하는 객체
  • porpertyKey: 메서드 데코레이터 이름
  • descriptor: 메서드 데코레이터의 디스크립터

function logTrade(target, key, descriptor) {  

	  console.log(target, key, descriptor);
		// "trade: {},  "placeOrder",  {
	  // "writable": true,
		//	"enumerable": false,
		//	"configurable": true
		//	}

    const originalCode = descriptor.value;  

    descriptor.value = function () { 

      console.log(`Invoked ${key} providing:`, arguments);
      return originalCode.apply(this, arguments);
    };

   return descriptor;  
}

class Trade {

  @logTrade  
  placeOrder(stockName: string, quantity: number,
             operation: string, tradedID: number) {

     // the method implementation goes here
    }
}

const trade = new Trade();
trade.placeOrder('IBM', 100, 'Buy', 123);

맵핑 타입

기존 타입에서 새 타입을 만들 수 있게 해주는 타입

// Person 타입 객체를 doStuff() 함수에 전달하는 상황

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

// doStuff 함수가 Person 타입을 수정 못하게 해야한다면?

const worker: Person = {name: "himchan", age: 30};

function doStuff(person: Person){
	person.age = 25;
}

// Person 타입의 프로퍼티는 읽기 전용이 아니므로 아래처럼 새로운 타입을 만들어야 할까?

interface ReadonlyPerson {
	readonly name: string;
	readonly age: number;
}

타입스크립트에는 Readonly 라는 맵핑 타입이 있다.

const worker: Person = {name: "himchan", age: 30};

function doStuff(person: Readonly<Person>){ // 맵핑 타입 Readonly 적용
	person.age = 25; // 컴파일 에러
}

Readonly 타입

type Readonly<T> = {
	readonly [P in keyof T]: T[P];
};

keyof

keyof는 인턱스 타입 쿼리, 프로퍼티 이름을 모아둔 유니온 타입이다.

Person 타입이 T라면 keyof T는 “name” | “age” 유니온이다.

// keyof와 T[P] 활용 예

// 개선 전

function filterBy<T>(property: any, value: any, array: T[]){
	return array.filter(item => item[property] === value);
}

/*
	존재하지 않는 프로퍼티 이름이나 잘못된 타입으로 호출하면 찾기 어려운 버그를 유발한다.
*/

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

const persons: Person[] = [
    { name: 'John', age: 32 },
    { name: 'Mary', age: 33 },
];

function filterBy<T, P extends keyof T>(
    property: P,
    value: T[P],
    array: T[]) {

    return array.filter(item => item[property] === value);     
}

console.log(filterBy('name', 'John', persons));

console.log(filterBy('lastName', 'John', persons));  // error

console.log(filterBy('age', 'twenty', persons));  // error

위의 코드에서 <T, P extends keyof T>는 T와 P라는 두 가지 제네릭 값을 받는 함수인 것을 의미한다.

T의 구체적인 타입은 Person, P는 name 혹은 age가 된다.

커스텀 맵핑 타입

커스텀 타입으로, readonly 프로퍼티를 수정할 수 있도록 만들어 보자

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

type Modifiable<T> {
	-readonly [P in keyof T]: T[P];
}

-표시는 모든 타입 프로퍼티를 삭제함을 의미한다.

이외 내장 맵핑 타입

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

// 선택사항으로 변경이 가능하다.

type Partial<T> = {
	[P in keyof T]?: T[P];
}

// 역으로 필수사항으로 변경도 가능하다.

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

type Required<T> = {
	[P in keyof T]-?: T[P];
}

📖 - 키워드는 타입 프로퍼티를 삭제한다!

한 개 이상의 맵핑 타입 사용

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

// worker1은 Person 타입을 유지하나 프로퍼티는 읽기 전용이며 선택사항이다.
const worker1: Readonly<Partial<Person>> 	= {name: "himchan"};

worker1.name = "chan" // error

Pick 내장 타입

type Pick<T, extends keyof T> {
	[P in K]: T[P];
}

T : 임의의 타입

K : T의 서브셋이며, 타입 T에서 유니온 K 중 해당 키를 가진 프로퍼티를 선택하라는 뜻

// Pick 사용

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

type PersonNameAddress = Pick <Person, 'name' | 'address'>;

const obj:PersonNameAddress = {
    name: 'him',
    address: 'bong'

}

PersonNameAddress는 name, address 키를 둘 다 가지고 있어야 한다.

조건 타입

T extends U ? X : Y

extends U : U에서의 상속

즉, T extends U 는 타입 T 값이 타입 U 변수에 할당될 수 있음을 의미한다.

// ex

function getProducts<T>(id?:T):
	T extends number ? Product : Product[];


//

class Product {
  id: number;
 }

function getProducts<T>(id?: T):
  T extends number ? Product : Product[] {
     if (typeof id === 'number') {
       return { id: 123 } as any
     } else {
       return [{ id: 123 }, {id: 567}] as any
     }       
}

let result1 = getProducts(123);

let result2 = getProducts();

타입스크립트가 타입 유추를 하지 않도록 타입 단언 as를 사용했다.

as any는 타입스크립트가 해당 타입을 오류로 인식해 불평하지 말라는 뜻

id 타입이 조건부 타입으로 선택되는 것이 아니므로 함수가 조건문을 평가할 수 없고, Product 타입으로 좁힐 수 없기 때문이다.

→ retrun 되는 값이 조건부로 선택되는 것이 아니라 {id: 123} 처럼 확정된 값이며, 이 값이 Product class의 타입인지 확인할 수 있는 방법이 없다.

// 비슷한 메서드 오버로딩의 예

class ProductService {

    getProducts();
    getProducts(id: number);
    getProducts(id?: number) {
        if (typeof id === 'number') {
          console.log(`Getting the product info for ${id}`);
        } else { 
          console.log(`Getting all products`);
        }
    }
}

const prodService = new ProductService();

prodService.getProducts(123);

prodService.getProducts();

Exclue 조건부 타입

type Exclude<T, U> = T extends U ? never : T;

ExcludeSMS U에 할당된 타입을 제외시킨다.

never 타입은 절대 존재하지 않아야 할 타입을 의미한다.

class Person {
	id: number;
	name: string;
	age: number;
}

Person 타입에서 name, age를 제외한 또 다른 타입을 만들려면?

type RemoveProps<T, K> = Exclude<keyof T, K>;

type RemainingProps = RemoveProps<Person, 'name' | 'age'>

타입 K : ‘name’ | ‘age’ 유니온

RemainingProps는 Person에서 name과 age과 제외된 id 프로퍼티만 있다.

type RemainingProps = RemoveProps<Person, 'name' | 'age'>;

type HiddenPerson = Pick<Person, RemainingProps>;

infer 키워드

상황 : 프로퍼티와 메서드가 선언된 인터페이스가 있고, 각 메서드를 Promise로 래핑하여 비동기적으로 실행해야 한다

interface SyncService {
	baseUrl: string;
	getA(): string;
}

T extends (...args: any[]) => any ?

args는 any이다, any의 배열에는 모든 타입이 들어있다. string, number … 그 수많은 타입을 매개변수로 받아, any라는 특정한 타입을 배출하는 메서드의 시그니처다.

함수의 타입을 반환하는 제네릭 R이 있다면, 아래처럼 적을 수 있다.

T extends (...args: any[]) => infer R ?

infer R은 getA() 메서드의 string 처럼 구체화된 반환 타입이 있는지 타입스크립트 컴파일러보고 유추하라고 하는 뜻이다.

마찬가지로, any[] 도 infer A로 바꿀 수 있다.


type ReturnPromise<T> =
    T extends (...args: infer A) => infer R ? (...args: A) => Promise<R> : T;   

type Promisify<T> = {
    [P in keyof T]: ReturnPromise<T[P]>;
};

interface SyncService {
    baseUrl: string; 
    getA(): string;
}

class AsyncService implements Promisify<SyncService> {
    baseUrl: string; 

    getA(): Promise<string> {
        return Promise.resolve('');
    }
}

let service = new AsyncService();


let result = service.getA(); // hover over result: it's of type Promise<string>

요약

  • 데코레이터를 활용해 클래스, 함수, 속성, 파라미터에 마타데이터를 추가할 수 있다.
  • 데코레이터는 타입 선언이나 클래스, 메서드, 속성, 파라미터의 작동 방식을 수정할 수 있다.
  • 맵핑 타입은 기존 타입 일부와 기존 타입을 기반으로 만든 파생 타입을 가진 새로운 애플리케이션을 만들 수 있게 해준다.
  • 조건부 타입은 어떤 타입을 사용할 지 결정을 미룰 수 있게 해주며, 결정은 런타임에 일어난다.