[Typescript] 타입 추론을 이용하되 코드 작성에 신중하자

김유진·2023년 4월 9일
0

Effective-TypeScript

목록 보기
13/28
post-thumbnail

타입스크립트에서 타입 추론은 매우 편리하며 하나하나 타입을 정의하지 않아도 되게 만들어 준다는 점에서 개발자가 타입에 대하여 신경써야 할 부분들을 줄여준다.
하지만 나의 의도와는 다르게 타입을 추론하게 될 수 있으므로 어떻게 동작할 것인지 예측하는 것은 디버깅을 더욱 쉽게 만든다.

타입스크립트의 타입 좁히기

타입스크립트에서 타입을 좁히는 과정을 알아보도록 하자. 타입을 좁혀나간다는 것은 어떤 의미일까?
지난 글에서 타입을 넓히기 때문에 상수와 타입이 추론된다는 점을 이해하였다. 이번 글에서는 타입 좁히기를 통하여 넓은 타입에서 작은 타입으로 타입을 체크하는 것을 지켜보고자 한다. 타입을 좁히는 방법에는 여러 가지가 존재한다.

타입을 좁히는 방법들

1. null 체크

const el = document.getElementById('foo'); //타입이 HTMLElement | null
if (el) {
  el //타입이 HTMLElement
  el.innerHTML = 'Party Time'.blink();
} else {
  el //타입이 null
  alert('No element #foo');
}

만약 elnull이라면, 분기문의 첫번째 블록이 실행되지 않는다. 즉, 첫 번째 블록에서 HTMLElement | null 타입의 null을 제외하기 때문이다.
그래서 더 좁은 타입이 되어서 작업이 훨씬 수월해진다.
이러한 과정을 타입 좁히기 라고 한다.

2. 예외문 사용하기

const el = document.getElementById('foo');
if (!el) throw new Error('Unable to find #foo');
el;
el.innerHTML = 'Party Time'.blink();

예외를 사용하여 null타입을 통과한 경우에는 HTMLElement 타입으로 둬 타입을 좁혀간다.

3. instanceof 사용하기

function contains(text: string, search: string|RegExp) {
  if (search instanceof RegExp) {
    search //타입이 RegExp
    return !!search.exec(text);
  }
  search //타입이 string
  return text.includes(search);
}

4. 속성 체크하기

interface A { a: number }
interface B { b: number }
function pickAB(ab: A | B ) {
  if ('a' in ab) {
    ab //타입이 A
  } else {
    ab //타입이 B
  }
  ab //타입이 A | B
}

위와 같이 타입을 좁히는 데에는 if문이 매우 잘 사용된다. 그러나, if문을 사용하여 타입을 좁힐 때 주의해야 할 점이 존재한다.

const el = document.getElementById('foo'); //타입은 HTMLElement | null
if (typeof el === 'object') {
  el;
}

타입스크립트에서 typeof nullobject이기 때문에, 조건문에서 null이 올바르게 제외되지 않는다. 이런 부분들에서 에러가 발생하지 않도록 주의하자.

타입을 좁히는 일반적인 방법

1. 명시적 '태그' 붙이기

interface UploadEvent { type: 'upload'; filename: string ; contents: string }
interface DownloadEvent { type: 'download'; filename: string; }
type AppEvent = UploadEvent | DownloadEvent;
function handleEvent(e: AppEvent) {
  switch (e.type) {
    case 'download':
      e
      break;
    case 'upload':
      e;
      break;

이러한 패턴은 태그된 유니온 또는 구별된 유니온이라고 부른다

2. 사용자 정의 타입 가드 작성하기

타입을 식별하지 못할 때, 식별을 돕기 위하여 커스텀 함수를 작성할 수 있다.
여기서 내가 크게 공감했던 코드가 있는데, 타입스크립트를 이용하여 배열을 작성할 때 가장 많이 마주쳤던 친구이다..

const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael']
const memebers = ['Janet', 'Michael'].map(
  who => jackson5.find(n => n === who)
); //타입은 (string | undefined)[]

여기서 undefined가 존재하기 때문에 작성하고 싶은 코드를 자유롭게 작성하지 못한 경우가 많다. 이 때 나는 filter함수를 이용하여 undefined라고 찍히는 친구들을 걸러내고 싶었는데...

const memebers = ['Janet', 'Michael'].map(
  who => jackson5.find(n => n === who)
).filter (who => who !== undefined); //타입이 (string | undefined)[]

이렇게 코드를 작성하여도 undefined는 걸러지지 않는다.. ㅠㅠ
이럴 때, 타입 가드를 작성하면 undefined를 걸러낼 수 있다.

function isDefined<T>(x: T | undefined): x is T {
  return x !== undefined;
}
const members = ['Janet', 'Michael'].map(
  who => jackson5.find(n => n === who)
).filter(isDefined); //타입은 string[]

이제 배열에 존재하는 undefined가 안지워진다고 고생하지 말고 타입 가드 함수를 작성하자~

객체를 생성할 때는 한꺼번에 생성하라

타입스크립트에서 타입은 일반적으로 동적으로 변경되지 않는다. 그래서 객체를 생성할 때에는 여러 속성을 포함하여 한꺼번에 생성해야, 타입 추론에 유리하다.

const pt = {};
pt.x = 3;
pt.y = 4;

이렇게 작성하게 되면 타입스크립트의 할당문에는 오류가 발생하게 된다. 왜냐하면 pt 타입은 {}값을 기준으로 타입을 추론하기 때문이다.
이러한 문제는 객체를 한번에 정의해야 해결할 수 있다.
만약 객체를 반드시 제각각 나누어 만들어야겠다면, 타입 단언문을 이용해야 한다.

const pt = {} as Point;
pt.x = 3;
pt.y = 4;

작은 객체를 조합하여 큰 객체를 만들 때는?

const pt = { x: 3, y: 4 }
const id = { name: 'Pythagoras' }
const namePoint = { ...pt, ...id };

객체 전개 연산자인 ...을 사용하면 큰 객체를 한꺼번에 만들 수 있다.

조건부 속성을 추가하고 싶다면?

declare let hasMiddle: boolean;
const firstLast = {first: 'Harry', last: 'Truman'};
const president = {...firstLast, ...(hasMiddle ? {middle: 'S'} : {})};

편집기에서 president에 마우스를 올려보자.

그럼 이렇게 선택적 속성으로 표기된다.
그런데 전개 연산자로 두 개 이상의 속성을 추가한다면?

declare let hasDates: boolean;
const nameTitle = { name: 'Khufu', title: 'Pharaoh'};
const pharaoh = {
  ...nameTitle,
  ...(hasDates? {start: -2589, end: -2566} : {})
};

이 경우에는 startend가 항상 함께 정의된다. 그리고 타입에서 각각의 속성을 읽어올 수가 없게 된다. 이럴 때에는 선택적 필드를 이용하여 헬퍼 함수로 원하는 대로 표현해볼 수 있겠다.

function addOptional<T extends object, U extends object>( a: T, b: U | null ): T & Partial<U> {
  return {...a, ...b};
}
const pharaoh = addOptional(
  nameTitle,
  hasDates ? {start: -2589, end: -2566} : null
);

일관성 있는 별칭을 사용하자.

별칭은 일관성 있도록 사용하는 것!

타입스크립트는 별칭을 사용하여 반복을 줄 일 수 있다.

const borough = {name: 'Brooklyn', location: [40.688, -73]};
const loc = borough.location;

이렇게 별칭을 지정하고 별칭의 값을 변경한다면, 원래 속성값도 변경이 된다.
그러나, 이렇게 별칭을 남발하면 제어 흐름을 분석하기 어렵다. 별칭을 신중하게 사용해야지 좋은 코드를 작성할 수 있다.

interface Polygon {
  exterior: Coordinate[];
  holes: Coordinate[][];
  bbox?: BoundingBox;
}
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox;
  if (polygon.bbox) {
    if (pt.x < box.x[0] || pt.x > box.x[1] || pt.y < box.y[0] || pt.y > box.y[1]) {
      return false;
    }
  }
}

여기서는 box.xundefined라는 이유로 밑줄이 쳐진다. 그 이유를 살펴보자!

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  polygon.bbox // 타입은 BoundingBox | undefined
  const box = polygon.bbox; // 타입은 BoundingBox | undefined
  if (polygon.bbox) {
    polygon.bbox // 타입은 BoundingBox 
    box // 타입은 BoundingBox | undefined
    if (pt.x < box.x[0] || pt.x > box.x[1] || pt.y < box.y[0] || pt.y > box.y[1]) {
      return false;
    }
  }
}

위와 같이 별칭을 사용해놓고 어떨 땐 사용 안하고.. 어떨 땐 사용하지 않는 일관성 있지 않은 코드를 작성하게 되면 당연히 타입이 그때그때 달라지는 것이다. polygon.bbox만 타입을 정제하는 데 성공했기 때문이다. 별칭을 일관성 있게 사용한다는 기본 원칙을 지키면 방지할 수 있는 에러이다.

객체 비구조화를 이용하자

코드를 읽는 이에게는 bboxbox로 사용되어 혼란을 초래한다. 그렇기 때문에 객체 비구조화를 사용하여 같은 이름으로 최대한 사용할 수 있다.

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const {bbox} = polygon;
  if (bbox) {
    const {x, y} = bbox;
    if (pt.x < x[0] || pt.x > x[1] ||
        pt.y < y[0] || pt.y > y[1]) {
      return false;
    }
  }
}

하지만 객체 비구조화를 이용할 때 아래 주의사항을 기억하자.

  • 전체 bbox속성이 아닌, x와 y가 선택적 속성일 때에는 타입 속성 체크를 꼭 진행하자.

별칭은 런타임에 혼동을 야기할 수 있다.

const { bbox } = polygon;
if (!bbox) {
  caculatePolygonBbox(polygon); //polygon.bbox가 채워진다.
}

bboxpolygon.bbox는 다른 값을 참조하게 된다.

function fn(p: Polygon) {/* ... */}
polygon.bbox //타입이 BoundingBox | undefined
if (polygon.bbox) {
  polygon.bbox //타입은 BoundingBox
  fn(polygon);
}

함수 호출은 polygon.bbox를 제거할 가능성이 있기 때문에 타입을 원래대로 되돌리는 것이 안전할 수 있다.

타입 추론에 문맥이 어떻게 사용되는지 이해하자.

타입스크립트는 단순히 타입을 추론할 때 해당 값만을 고려하는 것이 아니라 문맥까지 고려하여 타입을 추론합니다.

type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) {/*...*/}
setLanguage('JavaScript'); //정상
let language = 'JavaScript';
setLanguage(language); //'string'형식의 인수는 Language 매개변수에 할당될 수 없습니다.

이런 경우에는 language에 정확히 타입을 명시함으로써 해결할 수 있다. 두 가지 해결법을 확인해보자.

문맥과 값을 분리한다는 것..

1. language의 가능한 값 제한

let language: Language = 'JavaScript';
setLanguage(language);//정상

2. language를 상수로 만들기

const language = 'JavaScript';
setLanguage(language); //정상

const를 사용하여 language는 더이상 변경할 수 없다는 것을 알려주는 것이다. 그래서 더욱 정확한 타입인 문자열 리터럴로 판단되어 타입 체크를 통과한다.

튜플 사용 시 주의점

function panTo(where: [number, number]) {/*...*/}
panTo([10, 20]) //정상
const loc = [10, 20];
panTo(loc);
// ~~'number[]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당될 수 없습니다

문맥과 값을 분리하였기 때문에 오류가 발생하였다. locnumber[]로 추론되었기 때문! 해결해보도록 하자.

1. 타입 선언

const loc: [number, number] = [10, 20];
panTo(loc) //정상

2. 상수 문맥 제공

const는 값이 가리키는 참조가 변하지 않는 얕은 상수이다. 그러나 as const는 그 값이 내부까지 상수라는 사실을 타입스크립트에게 안내한다.

const loc = [10, 20] as const;
panTo(loc);

그런데 타입은 number[]가 아니라, readonly [10, 20]로 추론된다. 너무 과하게추론된 것을 알 수 있다.
오류를 고칠 수 있는 최선의 방법은 panTo 함수에 readonly 구문을 추가하는 것이다.

function panTo(where: readonly [number, number]) {/*...*/}
const loc = [10, 20] as const;
panTo(loc);

그러나 as const는 타입 정의에 실수가 있었을 때 그곳에서 오류가 발생하지 않고, 호출되는 곳에서 오류가 발생한다. 그렇기 때문에 여러 번 중첩된 객체에서 오류가 발생한다면 근본적인 원인을 파악하기 힘들어진다.

객체 사용 시 주의점

객체 사용 시에도 주의해야 한다.

type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage {
  language: Language;
  organization: string;
}
function complain(language: GovernedLanguage) {/*...*/}
complain({language: 'TypeScript', organization: 'Microsoft' });
const ts = {
  language: 'TypeScript',
  organization: 'Microsoft',
}
complain(ts);

여기서 language의 타입은 string으로 추론된다. 그래서 타입 선언을 따로 추가해주거나, 상수 단언(as const)를 이용하여 해결해줄 수 있다.

const ts = {
  language: 'TypeScript',
  organization: 'Microsoft',
} as const

0개의 댓글