본문 바로가기
백엔드 개발

#029. 리팩터링: any 보다 unknown 타입 사용하기 (feat. 이중 단언문)

by iamjoy 2023. 4. 5.

개념

(1) any와 unknown의 차이:
any는 어떤 타입 시스템에 혼란을 준다. 어떤 타입이든 any에 할당 가능하면서 any로 어떤 타입이든 할당할 수 있기 때문이다. 
'a' 라는 타입은 string 타입보다 작기 때문에 'a'는 string 타입에 할당 가능하다. 그런데 'a' as any라고 하면 number라는 더 큰 타입에도 할당가능하며, boolean 을 받을 수도 있게 된다. 다시 말해 타입 체커가 무용지물이 된다.
반면 unknown은 타입을 unknown 하나의 타입으로 줄이는 기능만 한다. unknown은 오직 unknown과 any에만 할당 가능하다. unknwon 타입으로 나오는 결과가 있다면 말그래도 "무슨 타입인 지 알 수 없음" 상태이고 어떤 타입인지 확인하지 않는 이상 사용이 어렵다. 하지만 이러한 특성 때문에 타입 체커 관점에서 any에 비해 안전한 타입으로 사용할 수 있다.
*참고로 never는 unknown 과 반대로 어떤 타입도 never에 할당할 수 없는 공집합의 개념이다.
(https://ui.toast.com/posts/ko_20220323)

unknown 타입으로 할당 받은 값은 다음과 같이 세 가지 방법으로 타입을 체크해 사용할 수 있다.
- 이중단언: as unknwon as <타입>의 형식으로 unknown 타입으로 타입을 줄인 뒤 의도된 타입으로 단언하는 방법이다. 책에서는 이 방법을 권장한다. "어떤 타입을 의도"하는 것이라면 단언을 사용하는 것이 적절하기 때문이다.
- instanceOf 를 사용할 수 있다. 참고로 instanceOf는 값공간을 체크한다.
- 타입가드를 만들 수 있다. 타입을 체크하기 위해서는 예시에 적힌 것처럼 객체확인, null 체크, 각각의 값들을 다 확인해야한다.

// 예시 3 : 이중 단언 - 특정 타입을 기준으로 타입 체크
const book3 = safeParseYAML(`name: 'The Hitchhiker's Guide to the Galaxy', author: 'Douglas Adams'`) as Book
alert(book2.title) // 오류: Book 형식에 title 속성이 없습니다.
book2('read') // 이 식은 호출할 수 없습니다.

// 예시 4: instance of
function processValue(val: unknown) {
    if(val instanceof Date) {
        return val;
    }
}

// 예시 5 : 타입 가드
function isBook(book: unknown): book is Book {
    return (typeof book === 'object' && book !== null && 'name' in book && 'author' in book)
}

 

이중 단언을 사용할 때 as any를 사용하는 것과 비교해볼 수 있다. any를 이중 단언문에 사용하면 나중에 이 이중 단언문을 분리하는 리팩터링 시에 어쨋든 any로 단언한 값이 나오게 되고 이로 인한 영향력이 퍼지게 된다.(타입 에러를 잡지 못함) 반면 unknwon은 분리되는 그 위치에서 에러를 발생시키므로 조금 더 안전하다. 

// as any as Book 과 차이
type Foo = {
    bar: string;
}

type Bar = {
    bar: string;
}

declare const foo: Foo;
let barAny = foo as any as Bar;
let barUnk = foo as unknown as Bar;

 

사용법

아래와 같이 선언된 requestDto를 테스트 하는 상황에서 unknwon의 이중 단언을 사용할 수 있다.
아래의 클래스에서 date 값은 timestamp 형식의 string을 받아 js-joda의 LocalDateTime 타입으로 변환하고 있다고 할 때,
이 Dto를 테스트 하기 위해서는 date 값으로 string을 넣어주되 이 타입은 LocalDateTime 타입이어야하는 모순적인 상황이 발생한다.

export class Request {
  @ApiProperty()
  @IsInt()
  id: number;

  @ApiProperty()
  @IsNotEmpty()
  @ToLocalDateTime()
  date: LocalDateTime;
}

이를 테스트 할 때 아래와 같이 string 값을 대입하되 타입을 LocalDateTime으로 단언해줌으로써 위의 모순적인 조건을 만족시키며 테스트를 할 수 있다. 기존 회사 코드를 작성할 때는 테스트는 어쩔 수 없는 부분이 있지 하면서 as any를 사용했는데 이펙티브 타입 스크립트 42장을 읽으며 보다 안전한 코드에 대해 배우고 적용하면서 즐거웠다!

  describe('POST /test', () => {
    it('요청이 성공하면 statusCode는 OK를 반환한다', async () => {
      // given
      const dto = new Request();
      dto.id = 0;
      dto.date =
        '2021-01-01 00:00:00' as unknown as LocalDateTime;

      // when
      const response = await request(app.getHttpServer())
        .post('/api/test')
        .send(dto);

      // then
      expect(response.body.statusCode).toBe(ResponseStatus.OK);
    });
  });