Dev/etc.

이펙티브 타입스크립트 아이템 31 ~ 40

takeU 2023. 6. 21. 10:34
반응형

아이템 31 - 타입 주변에 null 값 배치하기

  • 한 값의 null 여부가 다른 값의 null 여부에 암시적으로 관련되도록 설계하면 안 됨
  • API 작성 시에는 반환 타입을 큰 객체로 만들고, 반환 타입 전체가 null이거나 null이 아니게 만들어야 함
  • 클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 하는 것이 좋음
  • strictNullChecks는 반드시 필요함
function extent(nums: number[]) {
  let min, max;
  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }
  return [min, max];
}

// 최소값이나 최대값이 0인 경우, 값이 덧씌워짐
// nums 배열이 비어있다면 [undefined, undefined] 반환 > 반환 타입이 (number | undefined)[]로 추론됨
// 설계의 문제: min에 대한 체크만 하게되어 max는 undefined가 나올 수 있음

function extent(nums: num[]) {
  let result: [number, number] | null = null;
  for (const num of nums) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])]
    }
  }
  return result
}

// 반환 타입: [number, number] | null
  • null인 경우가 필요한 속성은 프로미스로 바꾸면 안됨
    • 모든 메서드를 비동기로 바꿔야 하기 때문

아이템 32 - 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기

// 1번
interface Layer {
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

// 2번
interface FillLayer {
  layout: FillLayout;
  paint: FillPaint;
}
interface LineLayer {
  layout: LineLayout;
  paint: LinePaint;
}
interface PointLayer {
  layout: PointLayout;
  paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
  • 의미상 1번보다 2번의 정의가 맞을때, 이러한 패턴을 태그된 유니온(또는 구분된 유니온)이라고 말함
interface FillLayer {
  type: 'fill';
...
}
interface LineLayer {
  type: 'line';
...
}
interface PointLayer {
  type: 'paint';
...
}
type Layer = FillLayer | LineLayer | PointLayer;
  • Layer를 나타내는 type 속성을 추가하면 ‘태그’로서 작동하며 어떤 타입의 Layer가 쓰이는지 판단됨
  • 또한 type을 판단하는 if문을 통해 범위를 좁히는데도 사용
  • 동시에 값이 있거나 없을 경우에도 태그된 유니온 패턴이 잘 맞음
interface Person {
  name: string;
// place와 date가 둘 다 동시에 있거나 동시에 없을 때, 하나의 객체로 모아서 설계
  birth?: {
    place: string;
    date: Date;
  }
}
  • 타입 구조에 손대지 못할 때도 인터페이스 유니온을 사용해서 속성간의 관계를 정의해줄 수 있음
interface Name {
  name: string;
}

interface PersonWithBirth extends Name {
  placeOfBirth: string;
  dateOfBirth: Date;
}

type Person = Name | PersonWithBirth;

아이템 33 - string 타입보다 더 구체적인 타입 사용하기

  • 문자열을 남발하여 선언된 코드를 피해야 함 / string 타입보단 구체화된 타입을 사용하는 것이 좋음
  • 문자열 리터럴 타입의 유니온을 사용해 타입 체크를 엄격히 하도록 해야함
  • 객체의 속성 이름을 매개변수로 받을 때는 keyof T를 사용하는 것이 좋음

BAD

interface Album {
  artist: string;
  title: string;
  releaseDate: string;  // YYYY-MM-DD
  recordingType: string;  // E.g., "live" or "studio"
}

GOOD

type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}

이러한 방식의 장점

  1. 명시적으로 정의함으로써 다른곳으로 값이 전달되어도 타입 정보가 유지
type RecordingType = 'studio' | 'live';

function getAlbumsOfType(recordingType: string): Album[] {
  // ...
}
/* getAlbumsOfType에서는 아무런 타입정보를 확인할 수 없지만 RecordingType을 통해 정의를 확인할 수 있다. */
  1. 타입을 명시적으로 정의하고 해당 타입의 의미를 설명하는 주석을 붙여 넣을 수 있음
/**  이 녹음이 어떤 환경에서 이루어 졌는지 확인하는 타입 */
type RecordingType = 'live' | 'studio';
  1. keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능
function pluck(record: any[], key: string): any[] {
  return record.map(r => r[key]);
}
/* any 타입이 있어서 정밀하지 못함. 특히나 return 타입이 any가 사용되어 좋지 않은 설계입니다. */

function pluck<T>(record: T[], key: string): any[] {
  return record.map(r => r[key]);
                      // ~~~~~~ '{}' 형식에 인덱스 시그니처가 없으므로
                      //       요소에 암시적으로 'any' 형식이 있습니다.
}
/* 제네릭타입을 도입하면 key의 범위가 너무 넓어 오류가 발생합니다. (4개의 값만 유효) */
// "artist" | "title" | "releaseDate" | "recordingType"

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}

function pluck<T>(record: T[], key: keyof T) {
  return record.map(r => r[key]);
}

declare let albums: Album[];
const releaseDates = pluck(albums, 'releaseDate'); // 타입이 (string | Date)[]
/* 타입 체커를 통과하며 타입 추론도 진행됩니다. 하지만 여전히 범위가 넓습니다.*/

function pluck<T, K extends keyof T>(record: T[], key: K): T[K][] {
  return record.map(r => r[key]);
}
/* 제네릭을 2개 사용하여 부분집합을 표현하였습니다. 더욱 타입 시그니처가 완벽해졌습니다. */

결론

  • string은 any와 같이 넓은 범위를 허용하여 타입간의 관계를 감추므로, string의 부분 집합을 정의하여 보다 정확한 타입을 사용해야 함

아이템 34 - 부정확한 타입보다는 미완성 타입을 사용하기

  • 예를 들어 경도와 위도를 나타내는 number 배열을 선언하는 경우에 number[] 보다는 튜플 타입 [number, number]로 선언하는것이 나음
  • 타입을 구체적으로 정의한다고 해서 정확도가 무조건 올라가지는 않음
    • any를 지양하되 부정확한 모델링은 피해야 함

아이템 35 - 데이터가 아닌, API와 명세를 보고 타입 만들기

  • 파일 형식, API, 명세 등 외부에서 비롯된 타입인 경우, 직접 작성하지 않고 자동으로 생성할 수 있음
  • 이 때 핵심은, 데이터가 아닌 명세를 참고해 타입을 생성해야 함
  • 예외 상황은 조건을 분기해 헬퍼 함수를 호출해 모든 타입을 지원하도록 할 수 있음

아이템 36 - 해당 분야의 용어로 타입 이름 짓기

  • 엄선된 타입, 속성, 변수의 이름은 의도를 명확히 하고 코드와 타입의 추상화 수준을 높여줌

동물들의 데이터베이스를 구축한다 가정

interface Animal {
    name: string;
    endangered: boolean;
    habitat: string;
}

const leopard: Animal = {
    name: 'Snow Leopard',
    endangered: false,
    habitat: 'tundra'
}
  • 해당 코드의 문제점
    1. name은 매우 일반적인 용어이므로 범위가 너무 포괄적임
    2. endangered 멸종 위기를 표현하는 변수를 boolean으로 설정하면, 이미 멸종된 동물에 값을 부여하는 것이 애매함
    3. habitat은 범위가 매우 넓은 string 타입일 뿐만 아니라, 뜻 자체도 애매함
    4. 객체의 변수명과 name 속성의 의도가 불분명함

개선

interface Animal {
    commonName: string;
    genus: string;
    species: string;
    status: ConservationStatus;
    climates: KoppenClimate[];
}
  • name은 commonName, genus, species 등 더 구체적인 용어로 대체
  • endangered는 동물 보호 등급에 대한 IUCN 표준 분류 체계 ConservationStatus로 변경
  • habitat은 기후를 뜻하는 climates로 변경되었으며, 쾨펜 기후 분류를 사용

주의사항

  • 동일한 의미를 나타낼 때는 같은 용어를 사용해야 함 / 의미적으로 구분이 되어야 하는 경우에만 다른 용어를 사용
  • 정식 명칭이 있는 경우 해당 명칭을 사용해야 함
  • 의미 없는 이름을 붙이면 안됨
  • 이름을 지을 때 포함된 내용이나 계산 방식이 아닌, 데이터 자체가 무엇인지 고려해야 함

아이템 37 - 공식 명칭에는 상표를 붙이기

interface Vector2D {
    _brand: '2d'; // 상표기법
    x: number;
    y: number;
}

function vec2D(x: number, y: number): Vector2D {
    return {x, y, _brand: '2d'}
}

function calculateNorm(p: Vector2D) {
    return '~~~'
}
  • 상표를 통해 함수가 Vector2D 타입만 받는 것을 보장

  • 타입 시스템에서 동작하지만, 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있음

  • 타입스크립트는 구조적 타이핑을 사용하기 때문에, 값을 세밀하게 구분하지 못하는 경우가 있음

  • 따라서 값을 구분하기 위해 공식 명칭이 필요하다면 상표를 붙이는 것을 고려해야 함

아이템 38 - any 타입은 가능한 한 좁은 범위에서만 사용하기

function processBar(b: Bar) {}

function f() {
    const x = expressionReturningFoo();
    processBar(x); // 'Foo' 형식의 인수는 'Bar' 형식의 매개변수에 할당될 수 없습니다.
}

문맥상 x라는 변수가 동시에 Foo 타입과 Bar 타입에 할당 가능하다면, 오류를 제거하는 방법은 두 가지

  1. 변수에 any 타입 지정 const x: any = ~~
  2. 인자에 전달 시 any 단언 f(x as any)

2번을 사용해야 하는 이유: 함수의 매개변수에서만 사용된 단언이므로 다른 코드에 영향을 미치지 않음

1번을 사용했을 때 문제가 되는 경우

function f1() {
    const x: any = expressionReturningFoo();
    processBar(x);
    return x;
}

function g() {
    const foo = f1(); // 타입이 any
    foo.fooMethod(); // 체크되지 않음
}

비슷한 관점에서 함수의 반환 타입을 추론할 수 있는 경우에도 반환 타입을 명시하는 것이 좋음.
즉, g 함수의 fooany 타입이기 때문에 g함수를 쓰는 곳이라면 연쇄적으로 영향을 미칠 수 있음
추가적으로, any를 사용해야 한다면 최소한의 범위에서 사용해야하며 차라리 @ts-ignore가 나음

아이템 39 - any를 구체적으로 변형해서 사용하기

any보다 더 구체적인 타입을 찾아 타입 안정성을 높여야 함

// 1. any 타입의 값을 그대로 정규식이나 함수에 넣는 것은 권장되지 않음
function getLengthBad(array: any) {} // x
function getLengthBad(array: any[]) {
    return array.length
} // o
  • array.length 타입이 체크됨
  • 리턴 타입이 number로 추론됨
  • 매개변수가 배열인지 체크됨

any의 구체화

배열의 배열 형태 - any[][]
내용을 모르는 객체 - {[key: string]: any}

{[key: string]: any}object의 차이

object 타입은 객체의 키를 열거할 수는 있지만, 속성에 접근할 수 없음

any를 구체화 하는 방법

type Fn0 = () => any; // 매개변수 없이 호출 가능한 모든 함수
type Fn1 = (arg: any) => any; // 매개변수 1개
type Fn2 = (...arg: any[]) => any; // 모든 개수의 매개변수

즉, any보다 더 정확하게 모델링할 수 있도록 구체적인 형태를 사용해야 함

아이템 40 - 함수 안으로 타입 단언문 감추기

  • 타입 단언문이 드러나 있는 것보다, 제대로 타입이 정의된 함수 안으로 단언문을 감추는 것이 더 좋은 설계
  declare function shallowEqual(a: any, b: any): boolean;
  function shallowObjectEqual<T extends object>(a: T, b: T): boolean {
    for (const [k, aVal] of Object.entries(a)) {
        if (!(k in b) || aVal !== b[k]) {
            return false
        }
    }
  }
  // k in b 체크로 b 객체어 k 속성이 있다는 것을 확인했지만, 오류가 발생
  // 실제 오류가 아니기 때문에 이럴 때 any로 단언을 해야 함

    function shallowObjectEqual<T extends object>(a: T, b: T): boolean {
        for (const [k, aVal] of Object.entries(a)) {
            if (!(k in b) || aVal !== (b as any)[k]) {
                return false
            }
        }
  }

출처: 이펙티브 타입스크립트