Node, Nest, Deno/🦁 Nest - Series

Nest + gql + typeORM(@nestjs/typeorm) (4) Mapped Types, @InputType으로 DTO 쉽게 만들기

DarrenKwonDev 2020. 11. 22. 01:51

docs.nestjs.com/graphql/mapped-types

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com

🚨 WARNING

This chapter applies only to the code first approach.

그리고, @nestjs/common에서 export하는 것이 아니라 @nestjs/graphql에서 export해야 합니다.

잘못 import되는 경우가 많아서 시간 낭비를 좀 했네요.

 

 

🦁 왜 필요한가?

 

다음과 같이 create를 실행하는 간단한 로직이 있다고 가정합니다. 인자에 Dto를 받고, Restaruant를 생성합니다.

createRestaurant(createRestaurantDto: CreateRestaurantDto): Promise<Restaurant> {
  const newRestaurant = this.restaurant.create(createRestaurantDto);
  return this.restaurant.save(newRestaurant);
}

 

그런데 client로 부터 받은 인자값을 체크하는 Dto는 다음과 같지만

import { ArgsType, Field } from '@nestjs/graphql';
import { IsBoolean, IsString, Length } from 'class-validator';

@ArgsType()
export class CreateRestaurantDto {
  @Field(() => String)
  @IsString()
  @Length(5, 10)
  name: string;
}

 

이로 인해 생성되는 Restaurant 인스턴스는 다음과 같습니다. 여기서 문제는 categoryName 속성을 Dto로부터 넘겨받지 못하고, default 값도 없습니다. nullable 처리도 안 해주었으므로 에러가 납니다. 

코드 상의 문제가 있는 겁니다.

import { Field, ObjectType } from '@nestjs/graphql';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@ObjectType() // nest
@Entity() // typeORM
export class Restaurant {
  @Field(() => Number) // nest(gql)
  @PrimaryGeneratedColumn() // typeORM
  id: number;

  @Field(() => String) // nest(gql)
  @Column() // typeORM
  name: string; // nest(typescript)

  @Field(() => String)
  @Column()
  categoryName: string;
}

 

따라서

1) Dto에서 categoryName을 넣고, 클라이언트에서 제대로 값을 넣어 보내주기

2) nullable 처리하거나 default 값을 주기

 

둘 중 하나를 해야 합니다. 그런데 잘 생각해보면, Nest에서는 gql schema와 entity를 동시에 생성했는데 dto를 만듦으로서 다시 중복되는 작업을 하게 생겼습니다. 

 

여기서 Nest는 gql schema와 entity를 이용해 dto를 쉽게 생성할 수 있는 Mapped Type을 제시합니다.

 

 

🦁 Mapped Type

docs.nestjs.com/graphql/mapped-types

 

Mapped Type은 base type으로부터 다른 버전을 만들 수 있게 해줍니다.

4가지의 Mapped Type이 존재합니다. Partial, Pick, Omit, Intersection

 

 

1) Partial 

 

 

소위 DTO라 부르는 input validation types를 만들 때 create와 update에 들어가는 변수는 똑같은 타입이되, create는 모두 required고 update는 모두 optional인 경우가 많습니다.

 

Nest는 PartialType() 이라는 기능을 제공하여 위와 같은 일을 쉽게 처리할 수 있도록 만들어줍니다. PartialType()은 모든 속성을 optional로 세팅한 타입을 만들어줍니다. 에를 들어서 create DTO를 다음과 같이 만들었다고 가정합시다.

@InputType()
class CreateUserInput {
  @Field()
  email: string;

  @Field()
  password: string;

  @Field()
  firstName: string;
}

 

기본적으로 위 타입들은 별다른 속성을 지정해주지 않았으니 required입니다.

* 참고로 @Field(() => [원하는 타입], { nullable: true }) 꼴로 적어주면 nullable을 만들어줄 수 있었죠.

 

여기에 PartialType() 을 덧씌워주면 모든 속성들은 optional이 됩니다. update DTO로 사용하면 되겠죠.

@InputType()
export class UpdateUserInput extends PartialType(CreateUserInput) {}

 

 

2) Pick

 

The PickType() function constructs a new type (class) by picking a set of properties from an input type. For example, suppose we start with a type like:

@InputType()
export class UpdateEmailInput extends PickType(CreateUserInput, ['email'] as const) {}

email 속성만 const로 가져오네요.

 

 

3) Omit

 

Pick이 특정 속성만 가져오는 것이라면 Omit은 특정 속성을 제외하고 전부 가져오는 것입니다.

영단어 뜻 그대로네요.

 

The OmitType() function constructs a type by picking all properties from an input type and then removing a particular set of keys. For example, suppose we start with a type like:

@InputType()
export class UpdateUserInput extends OmitType(CreateUserInput, ['email'] as const) {}

email을 제외한 다른 타입인 password와 firstname만 가져옵니다.

 

 

4) Intersection

당연히 합치는 겁니다.

 

The IntersectionType() function combines two types into one new type (class). For example, suppose we start with two types like:

@InputType()
class CreateUserInput {
  @Field()
  email: string;

  @Field()
  password: string;
}

@ObjectType()
export class AdditionalUserInfo {
  @Field()
  firstName: string;

  @Field()
  lastName: string;
}

위 두 타입을 아래로 합칠 수 있습니다.

@InputType()
export class UpdateUserInput extends IntersectionType(CreateUserInput, AdditionalUserInfo) {}

 

 

🦁 parent class should be @InputType

 

자, 여기서 주의할 점은, Mapped Type을 사용한 parent class가 전부 @InputType이라는 겁니다. 만약 @ObjectType 등에 Mapped Type을 적용하고 싶다면 마지막 인자에 InputType 값을 넘겨주어야 합니다. (정의된 부분 가서 순서를 확인해보세요)

두 번째 인자값을 주지 않으면 parent class에 적용된 데코레이터를 그대로 쓰게 됩니다. 처음부터 @InputType으로 만들었으면 사용할 필요가 없겠지만요.

@InputType()
export class UpdateUserInput extends PartialType(User, InputType) {}

 

 

🦁 Mapped Type 조합하기

 

앞서 언급한 4타입을 조합할 수 있습니다. 아래의 경우 

CrreateUserInput에서 email을 제외한 속성을 타입으로 만든 후 전부 optional하게 만들었군요.

@InputType()
export class UpdateUserInput extends PartialType(
  OmitType(CreateUserInput, ['email'] as const),
) {}

 

 

 

🦁 @ArgsType일 때는 validation을 해줬는데 Mapped Type을 사용하니 validation을 할수가 없는데?

 

네 못합니다.

대신 이 부분은 entity를 정의하는 부분에서 Validation을 해주면 됩니다. 

@ObjectType() // nest
@Entity() // typeORM
export class Restaurant {
  @Field(() => Number) // nest(gql)
  @PrimaryGeneratedColumn() // typeORM
  id: number;

  @Field(() => String) // nest(gql)
  @Column() // typeORM
  @IsString()
  @Length(5, 10)
  name: string; // nest(typescript)

 

 

🦁 그래서, 어떻게 gql schema, entity와 연계하여 dto를 작성할 수 있는가?

 

아래와 같은 gql Schema, Entity가 있다고 이를 생성하는 함수에 사용할 DTO와 아래와 같다고 가정합시다.

import { Field, ObjectType } from '@nestjs/graphql';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@ObjectType() // nest
@Entity() // typeORM
export class Restaurant {
  @Field(() => Number) // nest(gql)
  @PrimaryGeneratedColumn() // typeORM
  id: number;

  @Field(() => String) // nest(gql)
  @Column() // typeORM
  name: string; // nest(typescript)

  @Field(() => String)
  @Column()
  categoryName: string;
}
import { ArgsType, Field } from '@nestjs/graphql';
import { IsBoolean, IsString, Length } from 'class-validator';

@ArgsType()
export class CreateRestaurantDto {
  @Field(() => String)
  @IsString()
  @Length(5, 10)
  name: string;
}

 

 

그리고 이를 생성하는 함수에 넣기 위한 Input DTO를 만들어보려고 합니다.

우선 dto를 @ArgsType이 아닌 @inputType으로 변경해준 후

id는 자동 생성되는 부분이니까 id를 제외한 나머지를 가져오면 되니 OmitType을 이용할 수 있습니다.

@InputType()
export class CreateRestaurantDto extends OmitType(Restaurant, ['id'], InputType) {}

 

여기서 주의할 점은 인자로 InputType을 넣어야 한다는 겁니다. parent class인 Restaurant가 @ObjectType이므로 InputType으로의 변형이 필요하기 때문입니다.

 

 

 

🦁 그렇다면 parent class에 @InputType을 넣으면 이후 변형이 필요 없지 않을까?

 

이런 질문이 나오게된 경위는, Mapped Type을 사용하려고 할 때 InputType으로 변경하는 과정을 하지 않기 위해 다음과 같이 미리 InputType을 작성하면 되지 않겠느냐는 생각 때문입니다.

@InputType()
@ObjectType() // nest
@Entity() // typeORM
export class Restaurant {
  @Field(() => Number) // nest(gql)
  @PrimaryGeneratedColumn() // typeORM
  id: number;
  ... 중략

 

그러나 이 경우에는 

Schema must contain uniquely named types but contains multiple types named "Restaurant"

와 같은 에러가 납니다. 즉, 같은 이름을 가진 Schema가 있어서는 안된다는 거죠.

 

따라서 아래처럼 isAbstract를 true로 설정해두면 됩니다. abstract class니까 실제로 스키마로 생성하지는 않겠다는 거죠.

이와 관련해서는 class inheritance 부분을 살펴보시면 됩니다. docs.nestjs.com/graphql/resolvers#class-inheritance

 

주의할 점은, @InputType에 이름을 적어주는 것이 좋다는 것입니다.

export declare function InputType(name: string, options?: InputTypeOptions): ClassDecorator;

 

적지 않아도 당장은 오류가 나지않더라도 이후에 @ObjectType과 같이 사용하고, 관계가 맺어지면 같은 이름의 스키마가 있다는 다음과 같은 에러가 납니다.

Schema must contain uniquely named types but contains multiple types named "UserRestaurant".

 

따라서 아래와 같이 구성해주면 됩니다. 

@InputType('RestaurantIntputType', { isAbstract: true })
@ObjectType() // nest
@Entity() // typeORM
export class Restaurant {
  @Field(() => Number) // nest(gql)
  @PrimaryGeneratedColumn() // typeORM
  id: number;

 

여튼 이렇게 설정했다면, InputType으로 바꿔주는 작업은 하지 않아도 됩니다.

취향껏 Mapped Type에서 변환을 해주시던, parent class를 @InputType({isAbtract: true})로 두시던 편한대로 합시다.