(번역)더 좋은 타입스크립트 프로그래머로 만드는 11가지 팁

강엽이·2023년 1월 2일
133
post-thumbnail

원문 : https://dev.to/zenstack/11-tips-that-help-you-become-a-better-typescript-programmer-4ca1

타입스크립트를 배우는 것은 종종 재발견의 여정입니다. 여러분들의 타입스크립트에 대한 첫인상은 꽤 미심쩍을 수 있습니다. 타입스크립트는 단순히 컴파일러가 잠재적인 버그를 찾는 것을 돕기 위해 자바스크립트에 주석을 다는 방법 아닌가요?

비록 이 말이 일반적으로 맞지만, 계속 알아가다 보면 타입스크립트의 가장 놀라운 힘은 타입을 구성, 추론, 조작하는 데에 있다는 것을 알게 될 것입니다.

이 글에서는 언어를 최대한 활용하는 데 도움이 되는 몇가지 팁을 요약합니다.

#1 {집합(Set)}이라고 생각하기

타입은 프로그래머들에게는 일상적인 개념이지만, 그것을 간결하게 정의하는 것은 놀라울 정도로 어렵습니다. 대신 집합을 개념 모델로 사용하는 것이 도움이 됩니다.

예를 들어, 새롭게 배우는 사람들은 타입스크립트의 타입 작성 방식이 직관적이지 않다고 생각합니다. 매우 간단한 예시를 보겠습니다.

type Measure = { radius: number };
type Style = { color: string };

// typed { radius: number; color: string }
type Circle = Measure & Style;

논리적 AND의 의미로 연산자 &를 해석하면, Circle은 겹치는 필드가 없는 두 가지 타입의 결합인 더미 타입이라고 예상할 수 있습니다. 하지만 타입스크립트는 논리적 의미로 동작하는 방식이 아닙니다. 대신에 타입을 집합이라고 생각하는 것이 동작을 이해하기 쉬울 것입니다.

  • 모든 타입은 값의 집합입니다.
  • string, number와 같은 일부 집합들은 무한하고, boolean, undefined 등은 유한합니다.
  • unknown은 모든 값을 포함하는 범용 집합이고, never는 아무 값을 포함하지 않는 집합입니다.
  • Measure 타입은 radius이라는 숫자 필드를 포함하는 모든 객체의 집합니다. Style도 마찬가지입니다.
  • & 연산자는 인터섹션(Intersection, 교집합)을 만듭니다. Measure & Styleradiuscolor 필드를 모두 포함하는 객체 집합을 나타냅니다. 이것은 사실상 더 작은 집합이지만, 더 공통적으로 사용할 수 있는 필드를 포함합니다.
  • 마찬가지로, | 연산자는 더 큰 집합을 만들지만 잠재적으로 공통적으로 사용 가능한 필드가 더 적습니다. (두 객체 타입이 합쳐져 있다면).

집합은 또한 할당 가능성을 이해하는 데 도움이 됩니다. 할당은 값의 타입이 대상 타입의 하위 집합인 경우에만 허용됩니다.

type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';

// string은 ShapeKind의 하위 집합이 아니므로 허용되지 않습니다.
shape = foo;

// ShapeKind는 string의 하위 집합이므로 허용됩니다.
foo = shape;

다음 글에서는 타입을 집합으로 생각하는 것에 대한 훌륭한 설명을 제공합니다.

#2 선언된 타입과 좁혀진(narrowed) 타입의 이해

타입스크립트의 매우 강력한 특징 중 하나는 제어 흐름에 따라 자동으로 타입을 좁히는 것입니다. 이것은 변수가 코드 위치의 특정 지점에서 연관된 두 가지 타입, 즉 선언 타입과 좁혀진 타입을 가짐을 의미합니다.

function foo(x: string | number) {
  if (typeof x === 'string') {
    // x'의 타입은 string타입으로 좁혀졌습니다. 따라서 .length가 가능합니다.
    console.log(x.length);

    // 할당을 하게되면 좁혀진 타입이 아닌 선언한 타입이 됩니다.
    x = 1;
    console.log(x.length); // x는 지금 number 타입이므로 불가능합니다.
    } else {
        ...
    }
}

#3 옵셔널 필드 대신에 구분된 유니온 사용

Shape와 같은 다형성 타입 집합을 정의할 때에는 다음과 같이 쉽게 시작할 수 있습니다.

type Shape = {
  kind: 'circle' | 'rect';
  radius?: number;
  width?: number;
  height?: number;
}

function getArea(shape: Shape) {
  return shape.kind === 'circle' ? 
    Math.PI * shape.radius! ** 2
    : shape.width! * shape.height!;
}

radius, width, height 필드에 접근할 때 null이 아니라는 단언(assertion)이 필요합니다. 왜냐하면, kind와 다른 필드들 사이에 확립된 관계가 없기 때문입니다. 이때는 대신에 유니온으로 구분하는 것이 더 좋은 방법입니다.

type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function getArea(shape: Shape) {
    return shape.kind === 'circle' ? 
        Math.PI * shape.radius ** 2
        : shape.width * shape.height;
}

타입을 좁혔기 때문에 강제할 필요가 없어졌습니다.

#4 타입 단언을 피하기 위한 타입 명제 사용

올바른 방식으로 타입스크립트를 사용한다면, 명시적 타입 단언(value as SomeType과 같은)을 사용하는 경우는 거의 없을 것입니다. 하지만 가끔 다음과 같은 충동을 느낄 수 있습니다.

type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function isCircle(shape: Shape) {
  return shape.kind === 'circle';
}

function isRect(shape: Shape) {
  return shape.kind === 'rect';
}

const myShapes: Shape[] = getShapes();

// 타입스크립트가 필터링 된 것을 모르기 때문에 에러가 발생합니다.
// 타입 좁히기
const circles: Circle[] = myShapes.filter(isCircle);

// 다음과 같은 단언을 추가하고 싶을 수 있습니다.
// const circles = myShapes.filter(isCircle) as Circle[];

보다 우아한 해결책은 isCircleisRect를 타입 명제를 반환하도록 변경하여 filter 호출 후 타입스크립트가 타입을 좁힐 수 있도록 도와주는 것입니다.

function isCircle(shape: Shape): shape is Circle {
    return shape.kind === 'circle';
}

function isRect(shape: Shape): shape is Rect {
    return shape.kind === 'rect';
}

...
// 이제 Circle[] 타입을 올바르게 유추합니다.
const circles = myShapes.filter(isCircle);

#5 유니온 타입 분배 제어

타입 추론은 타입스크립트의 본능입니다. 대부분의 경우 자동으로 작동합니다. 하지만, 애매한 경우에는 여러분들이 개입해야할 수 있습니다. 분포 조건 타입은 이 경우 중 하나입니다.

만약 인풋 타입이 아직 배열이 아닌 경우 배열 유형을 반환하는 ToArray 헬퍼 타입이 있다고 가정해봅시다.

type ToArray<T> = T extends Array<unknown> ? T: T[];

다음 타입이 어떻게 추론될까요?

type Foo = ToArray<string|number>;

정답은 string[] | number[]입니다. 그러나 애매합니다. 대신 (string | number)[]는 어떨까요?

기본적으로, 타입스크립트에서 제네릭 매개변수(여기서는 T)가 유니온 타입(여기서는 string | number)을 만나면 각 구성요소로 분배되므로 string[] | number[]로 표시됩니다. 이 동작은 특수 구문을 사용하여 다음과 같이 T를 한 쌍의 []로 감싸면 변경될 수 있습니다.

type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>;

이제 Foo(string | number)[] 타입으로 추론됩니다.

#6 철저한 검사를 통한 컴파일시 처리되지 않은 케이스 체크

열거형(enum)을 스위치-케이스로 활용할 때, 다른 프로그래밍 언어에서처럼 예상치 못한 경우를 무시하지 않고 적극적으로 오류를 처리하는 것이 좋습니다.

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    default:
      throw new Error('Unknown shape kind');
  }
}

타입스크립트에서 never 타입을 사용하면 정적 타입 검사시에 오류를 조기에 발견할 수 있습니다.

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    default:
      // 아래에서 타입 확인 오류가 발생합니다.
      // 만약 shape.kind가 위에서 처리되지 않았다면.
      const _exhaustiveCheck: never = shape;
      throw new Error('Unknown shape kind');
  }
}

이를 통해 새로운 shape에 kind를 추가할 때 getArea 기능을 업데이트 하는 것을 잊지 않을 수 있습니다.

이 기술의 근거는 never형은 never 타입 이외에는 할당할 수 없다는 것입니다. shape.kind의 모든 후보가 케이스 상태에 의해 소진되면 default에 도달할 수 있는 타입은 없습니다. 하지만 어떤 후보가 커버되지 않은 경우 default 지점으로 유출되어 잘못된 할당이 발생합니다.

#7 interface보다 type을 사용

타입스크립트에서 typeinterface는 객체를 선언할 때 매우 유사한 구조입니다. 논란의 여지가 있지만 대부분의 경우 일관되게 type을 사용하고 다음 중 하나에 해당하는 경우에만 interface를 사용하는 것이 좋습니다.

  • interface의 "merging" 기능을 활용하고 싶을 때.
  • 클래스/인터페이스 계층을 포함하는 OO 스타일 코드를 가지고 있을 때.

위의 경우가 아니면, 항상 더 다용도적인 type을 사용하면 코드가 더 일관성 있게 됩니다.

#8 적절한 상황에는 배열보다는 튜플을 사용

객체 타입은 구조화된 데이터를 선언하는 일반적인 방법이지만, 가끔 여러분이 간결한 표현을 원하면 대신 단순 배열을 사용할 수도 있습니다. 예를 들어, Circle은 다음과 같이 정의될 수 있습니다.

type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0];  // [kind, radius]

하지만 이 선언은 불필요하게 느슨하고 ['circle', '1.0'] 등을 만들어 쉽게 오류를 만들 수 있습니다. 대신 튜플을 사용하여 보다 엄격하게 만들 수 있습니다.

type Circle = [string, number];

// 아래에서 오류가 발생합니다.
const circle: Circle = ['circle', '1.0'];

React의 useState는 튜플의 좋은 사용 예시입니다.

const [name, setName] = useState('');

간결하면서 안전한 타입입니다.

#9 추론된 타입이 일반적이거나 구체적이도록 제어

타입스크립트는 타입 추론을 만들 때 합리적인 기본 동작을 사용하며, 이는 일반적인 경우에 코드를 쉽게 작성할 수 있도록 돕는 것을 목표로 합니다(타입에 명시적인 주석을 달 필요가 없음). 이 기본 동작을 조정할 수 있는 몇 가지 방법이 있습니다.

  • 가장 구체적인 타입으로 범위를 좁히려면 const를 사용합니다.
let foo = { name: 'foo' }; // typed: { name: string }
let Bar = { name: 'bar' } as const; // typed: { name: 'bar' }

let a = [1, 2]; // typed: number[]
let b = [1, 2] as const; // typed: [1, 2]

// typed { kind: 'circle; radius: number }
let circle = { kind: 'circle' as const, radius: 1.0 };

// circle이 const 키워드를 사용해 초기화 되지 않았다면
// 다음이 동작하지 않습니다.
let shape: { kind: 'circle' | 'rect' } = circle;
  • 추론된 타입에 영향을 미치지 않고 선언을 확인하려면 satisfies를 사용합니다.
    다음 예를 고려해봅시다.
type NamedCircle = {
    radius: number;
    name?: string;
};

const circle: NamedCircle = { radius: 1.0, name: 'yeah' };

// circle.name는 undefined일 수도 있기 때문에 오류가 발생했습니다.
console.log(circle.name.length);

변수에 문자 값을 제공했음에도 불구하고 circle의 선언 타입인 NamedCircle에 따르면 name 필드는 undefined 일수도 있기 때문에 오류가 발생합니다. 물론 우리는 :NameCircle 타입 주석을 삭제할 수 있지만 circle 객체의 유효성을 검사하지 못합니다. 정말 딜레마입니다.

다행히도 Typescript 4.9는 추론된 타입을 변경하지 않고 타입을 확인할 수 있는 새로운 satisfies 키워드를 도입했습니다.

type NamedCircle = {
    radius: number;
    name?: string;
};

// radius가 NamedCircle을 위반하여 오류가 발생합니다.
const wrongCircle = { radius: '1.0', name: 'ha' }
    satisfies NamedCircle;

const circle = { radius: 1.0, name: 'yeah' }
    satisfies NamedCircle;

// circle.name는 undefined가 될 수 없습니다.
console.log(circle.name.length);

수정된 버전은 객체 리터럴이 NamedCircle 타입과 일치하도록 보장되며 추론된 타입에는 null이 불가능한 name 필드가 있습니다.

#10 추가 제네릭 타입 매개 변수를 만들기 위해 infer를 사용

유틸리티 함수 및 타입을 설계할 때는 지정된 타입 매개 변수에서 추출된 타입을 사용해야할 필요성을 느끼는 경우가 많습니다. 이런 상황에서 infer 키워드는 유용합니다. 새로운 타입 매개변수를 즉시 추론할 수 있도록 도와줍니다. 다음은 두 가지 간단한 예시입니다.

// Promise에서 포장되지 않은 타입을 가져옵니다.
// T가 Promise가 아니라면 결과는 달라지지 않습니다.
type ResolvedPromise<T> = T extends Promise<infer U> ? U : T;
type t = ResolvedPromise<Promise<string>>; // t: string

// 배열 T의 평탄화된 타입을 가져옵니다.
// T가 배열이 아니라면 결과는 달라지지 않습니다.
type Flatten<T> = T extends Array<infer E> ? Flatten<E> : T;
type e = Flatten<number[][]>; // e: number

T extends Promise<infer U>에서 infer 키워드가 작동하는 방법은 다음과 같이 이해할 수 있습니다. T가 일부 인스턴스화된 일반 Promise 타입과 호환된다고 가정하면 타입 매개 변수 U를 임시로 적용하여 작동합니다. 따라서 TPromise<string>으로 인스턴스화 되면 U의 값은 string이 됩니다.

#11 타입 조작으로 창의성을 발휘하여 DRY 상태를 유지

타입스크립트는 코드 중복을 최소화 하는 데 도움이 되는 강력한 타입 조작 구문과 매우 유용한 유틸리티 도구를 제공합니다. 다음은 몇 가지 사례입니다.

  • 필드 선언을 복제하는 대신
type User = {
    age: number;
    gender: string;
    country: string;
    city: string
};
type Demographic = { age: number: gender: string; };
type Geo = { country: string; city: string; };

, Pick 유틸리티를 사용하여 새로운 타입을 추출합니다.

type User = {
    age: number;
    gender: string;
    country: string;
    city: string
};
type Demographic = Pick<User, 'age'|'gender'>;
type Geo = Pick<User, 'country'|'city'>;
  • 함수의 반환 타입을 복제하는 대신 사용합니다.
function createCircle() {
    return {
        kind: 'circle' as const,
        radius: 1.0
    }
}

function transformCircle(circle: { kind: 'circle'; radius: number }) {
    ...
}

transformCircle(createCircle());

, ReturnType<T>을 사용하여 타입을 추출합니다.

function createCircle() {
    return {
        kind: 'circle' as const,
        radius: 1.0
    }
}

function transformCircle(circle: ReturnType<typeof createCircle>) {
    ...
}

transformCircle(createCircle());
  • 두 가지 타입(여기서는 config와 Factory의 타입)의 모양을 병렬로 동기화 하는 대신 다음과 같이 할 수 있습니다.
type ContentTypes = 'news' | 'blog' | 'video';

// 사용할 수 있는 콘텐츠 타입을 표시하기 위한 config입니다.
const config = { news: true, blog: true, video: false }
    satisfies Record<ContentTypes, boolean>;

// 콘텐츠를 생성하는 factory 입니다.
type Factory = {
    createNews: () => Content;
    createBlog: () => Content;
};

, Mapped TypeTemplate Literal Type을 사용하여 config의 모양을 기반으로 적절한 factory 타입을 자동으로 유추합니다.

type ContentTypes = 'news' | 'blog' | 'video';

// 메서드 목록이 추출된 제네릭 factory 타입입니다. 
// 주어진 Config의 모양을 기반으로 합니다.
type ContentFactory<Config extends Record<ContentTypes, boolean>> = {
  [k in string & keyof Config as Config[k] extends true ? `create${Capitalize<k>}` : never]: () => Content;
};

// 사용할 수 있는 콘텐츠의 타입을 표시하기 위한 config입니다.
const config = { news: true, blog: true, video: false }
    satisfies Record<ContentTypes, boolean>;

type Factory = ContentFactory<typeof config>;
// Factory: {
//     createNews: () => Content;
//     createBlog: () => Content; 
// }

여러분들의 상상력을 사용하면 탐험할 수 있는 무한한 잠재력을 발견할 수 있습니다.

마무리

이 글은 타입스크립트 언어로 된 비교적 고급 주제들을 다루었습니다. 실제로는 직접 적용하는 것이 일반적이지 않을 수도 있습니다. 그러나 이러한 기술은 Prisma, tRPC와 같은 타입스크립트 용으로 특별히 설계된 라이브러리에서 많이 사용됩니다. 요령을 알면 이러한 도구가 어떻게 동작하는지 더 잘 알 수 있습니다.

P.S. 우리는 Next.js + 타입스크립트를 사용하여 안전한 CRUD 애플리케이션을 구축하기 위한 툴킷인 ZenStack을 개발하고 있습니다. 우리의 목표는 여러분들이 상용 코드를 작성하는 시간을 절약하고 중요한 사용자 경험을 개발할 수 있는 데 집중할 수 있도록 하는 것입니다.

profile
FE Engineer

3개의 댓글

comment-user-thumbnail
2023년 1월 9일

Can you please share us an image depicting the issue? https://www.emorypatient-portal.com/

답글 달기
comment-user-thumbnail
2023년 1월 14일

타입스크립트는 쓰다보면 선택지가 너무 많아서 규칙을 정하기 어려운 경우가 상당히 많은 것 같아요 ㅎㅎ
좋은 글 공유 감사해요

답글 달기