IT/TypeScript

[Typescript] Generic을 활용한 class constructor 간소화하기

JeongHyeongKim 2023. 5. 26. 20:59

0. Table Of Contents

1. 서론

class 정의시, constructor를 정의하곤 했다. 일반적인 constructor를 생성시, 아래와 같은 형태를 띈다

export class ModbusConnection {
  constructor(modbusClient: ModbusRTU, deviceId: any, ipAddress: string, modelId: any, nestedDeviceIdList: Device[]) {
    this.modbusClient = modbusClient;
    this.deviceId = deviceId;
    this.ipAddress = ipAddress;
    this.modelId = modelId;
    this.nestedDeviceIdList = nestedDeviceIdList;
  }

  modbusClient: ModbusRTU;

  deviceId: any;

  ipAddress: string;

  modelId: any;

  nestedDeviceIdList: Device[];
}

 

생성자로 인자를 받아 그대로 객체를 생성하는 것임에도 불구하고 불필요하게 코드가 자꾸 사용되는것 같다는 생각이 들었다. 이를 어떻게 하면 코드를 좀 더 간결하게 줄일 수 있을 까 생각하게 되었으며 이를 해결하기 위해 typescript의 generic을 사용하여 해보기로 하였다.

 

2. 이번 문제의 key는 왜 Generic인가?

한가지 타입이 아닌 여러가지 타입을 기반으로 동작하는 function을 설계할 때 주로 사용한다. 이와 비슷한 역할을 하는 any 타입으로 대체할 수 있었지만, 나는 아래와 같은 이유로 generic을 사용하게 되었다. generic 뜻을 찾아보니 '데이터 타입을 일반화 시키는 것'이라고 한다. generic을 이용하면 아래와 같이 개선할 수 있다고 생각이 들었다.

  • any를 사용하면 어떠한 타입이라도 모두 받을 수 있지만, 어떤 타입이 들어갔는지에 대해 알 수 없다.
  • any는 타입 체크를 하지 않기 때문에 typescript의 최고 장점인 컴파일에서 오류를 잡을 수 없다는 단점이 존재한다.
  • 그러나 generic은 개발자로부터 이러한 타입을 쓸 것이다 라는 것을 받기 때문에 컴파일 단계에서 오류도 잡을 수 있고 유동적으로 타입을 변경할 수 있다고 생각했다.

3. Generic

위에서 언급했지만, 한 번 더 정리하면 ‘데이터 타입을 일반화 시키는 것’을 의미한다. 기본적인 Generic을 Typescript화 시키면, 아래와 같이 사용할 수 있다.

 

function identity<T>(argument: T): T {
  return arg;
}

T는 Type을 지칭하는 약어로, 통상적인 약속으로 T라고 명명한다.

 

 

generic을 이용할 시, extends를 이용하여 특정 object가 가진 property들에 한정시킬 수도 있다.

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


function identity<T extends keyof Item>(argument: T): T {
  return argument;
}

 

4. Generic Type (Utility Type)

https://www.typescriptlang.org/docs/handbook/utility-types.html페이지에 많은 유틸리티 타입이 존재하지만, 이 중 내가 이번에 사용한 type만 서술해본다.

 

4.1. Partial<T>

입력되는 특정 type의 부분집합을 type으로 구성 할 수 있다.

interface Todo {
  title: string;
  description: string;
}
 
function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
  return { ...todo, ...fieldsToUpdate };
}
 
const todo1 = {
  title: "organize desk",
  description: "clear clutter",
};
 
const todo2 = updateTodo(todo1, {
  description: "throw out trash",
});

 

4.2. Pick<T, Keys>

특정 Type의 특정 Property를 이용하여 새로운 타입으로 정의할 수 있다.

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}
 
type TodoPreview = Pick<Todo, "title" | "completed">;
 
const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
};

 

5. 최종 적용 코드

Class를 선언하게 되면 this를 사용할 수 있기 때문에, Object.assign을 이용하여 한번에 입력 할 수 있을 것이라고 생각했으며, 간혹 어쩔 수 없이 any를 사용하는 경우, function이 들어가는 case를 막기 위해 아래와 같은 코드를 생각하게 되었다.

type ClassProperties<T> = Pick<T, { [P in keyof T]: T[P] extends Function ? never : P }[keyof T]>;

 

위 코드를 설명하자면 아래왜 같이 설명 할 수 있다.

[입력된 type의 Property] : Type의 Property가 Function type을 상속 받으면 ? 내보내지 않는다 : type

 

즉, 입력된 type의 property 중 function type을 제외한 모든 property가 pick되는 구조이다. 이를 이용하여 위 코드를 아래와 같이 적

용 할 수 있었다.

export class ModbusConnection {
  constructor(data: ClassProperties<ModbusConnection>) {
    Object.assign(this, data);
  }

  modbusClient: ModbusRTU;

  deviceId: any;

  ipAddress: string;

  modelId: any;

  nestedDeviceIdList: Device[];
}

constructor에서 약간의 연산이 들어가면 위 코드 중 constructor 쪽이 조금 더 길어지겠지만, constructor 인자로 받는 부분과 this.{property}=arg가 없어지니 코드가 간결해졌으며 보기 좋아진거같다.