TypeScript 4장(33 ~ 37)

이종서·2022년 12월 26일
0

TypeScript

목록 보기
6/9

Item 33. string 타입보다 더 구체적인 타입 사용하기

string 타입으로 변수를 선언하려 한다면, 혹시 그보다 더 좁은 타입이 적절하지는 않을지 검토해 보아야 합니다.

interface Album {
  artist: string;
  title: string;
  releaseDate: string; // YYYY-MM-DD
  recordingType: string; // ex) 'live' 또는 'studio'
}

위에 코드는 string 타입이 남발된 모습입니다.

  1. recordingType에 소문자 대신 대문자 이 두값이 모두 문자열이고 Album 타입에 할당 가능하며 타입체커를 통과합니다.
  2. releaseDate 필드의 값의 주석에 설명된 형식과 다르게 들어갈 수 있습니다.
  3. string 타입의 범위가 넓기 때문에 제대로 된 Album 객체를 사용하더라도 매개변수 순서가 잘못된 것이 오류로 드러나지 않습니다.

위에 오류를 방지하기 위해 범위를 좁히는 방법으로는 releaseDate 필드는 Date 객체를 사용 / recordingType 필드는 유니온 타입으로 정의
(enum을 사용할 수도 있지만 일반적으로는 추천하지 않습니다.)

type RecordingType = 'studio' | 'live';

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

이러한 방식에는 3가지 장점이 있습니다.

  1. 타입을 명시적으로 정의함으로써 다른 곳으로 값이 전달되어도 타입 정보가 유지됩니다.
  2. 타입을 명시적으로 정의하고 해당 타입의 의미를 설명하는 주석을 붙여 넣을 수 있습니다.
  3. keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능해집니다.

타입 시그니처를 개선하는 첫 단계로 제너릭 타입을 도입해 보겠습니다.

function pluck<T>(records: T[], key: string): any[] {
  return records.map(r => r[key]);
  					   // ~~~~~~ '{}'형식에 인덱스 시그니처가 없으므로
  					   //         요소에 암시적으로 'any' 형식이 있습니다.
}

key의 타입이 string 이기 때문에 범위가 너무 넓다는 오류를 발생시킵니다.
그러므로 string을 keyof T로 바꾸면 됩니다.

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

그런데 key의 값으로 하나의 문자열을 넣게 되면, 그 범위가 너무 넓어서 적절한 타입이라고 보기 어렵습니다.
따라서, 범위를 더 좁히기 위해서, keyof T의 부분 집합으로 두 번째 제너릭 매개변수를 도입해야 합니다.

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

이제 타입 시그니처가 완벽해졌습니다.

####⭐️ 요약
📍 모든 문자열을 할당 할 수 있는 string 타입보다는 더 구체적인 타입을 사용하는 것이 좋습니다.
📍 변수의 범위를 보다 정확하게 표현하고 싶다면 string 타입보다는 문자열 리터럴 타입의 유니온을 사용하면 됩니다. 타입 체크를 더 엄격히 할 수 있고 생산성을 향상시킬 수 있습니다.
📍 객체의 속성 이름을 함수 매개변수로 받을 때는 string보다 keyof T를 사용하는 것이 좋습니다.

Item 34. 부정확한 타입보다는 미완성 타입을 사용하기

타입 선언의 정밀도를 높이는 일에 주의를 기울여야 합니다. 실수가 발생하기 쉽고 잘못된 타입은 차라리 타입이 없는 것보다 못할 수 있기 때문입니다.

<JSON으로 정의된 Lisp와 비슷한 언어의 타입 선언>

12
"red"
["+", 1, 2]	// 3
["/", 20, 2] // 10
["case", [">", 20, 10], "red", "blue"] // "red"
["rgb", 255, 0, 127] // "#FF007F"

다음은 이런 동작을 모델링해 볼 수 있는 입력값의 전체 종류입니다.
1. 모두 허용
2. 문자열, 숫자, 배열 허용
3. 문자열, 숫자, 알려진 함수 이름으로 시작하는 배열 허용
4. 각 함수가 받는 매개변수의 개수가 정확한지 확인
5. 각 함수가 받는 매개변수의 타입이 정확한지 확인

  • 처음 두개의 옵션
type Expression1 = any;
type Expression2 = number | string | any[];
  • 표현식의 유효성을 체크하는 테스트 세트를 도입
const tests: Expression2[] = [
  10,
  "red",
  true, // 'true' 형식은 'Expression2' 형식에 할당할 수 없습니다.
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"], // 값이 너무 많습니다.
  ["**", 2, 31], // "**"는 함수가 아니므로 오류가 발생해야 합니다.
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0] // 값이 너무 많습니다.
]

정밀도를 끌어 올리기 위해서 튜플의 첫 번째 요소에 문자열 리터럴 타입의 유니온을 사용해 보겠습니다.

type FnName = '+' | '-' | '*' | '/' | '>' | '<' | 'case' | 'rgb';
type CallExpression = [FnName, ...any[]];
type Expression3 = number | string | CallExpression;

const tests: Expression2[] = [
  10,
  "red",
  true, // 'true' 형식은 'Expression2' 형식에 할당할 수 없습니다.
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"], // 값이 너무 많습니다.
  ["**", 2, 31], // "**"는 함수가 아니므로 오류가 발생해야 합니다.
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]
]

여러 인터페이스를 나열해서 호출 표현식을 작성합니다. 고정 길이 배열은 튜플 타입으로 가장 간단히 표현할 수 있기 때문에, 어색해 보일 수 있지만 다음 코드처럼 구현할 수 있습니다.

type Expression4 = number | string | CallExpression;
type CallExpression = MathCall | caseCall | RGBCall;

interface MathCall {
  0: '+' | '-' | '*' |'/' |'<'| '>';
  1: Expression4;
  2: Expression4;
  length:3
}

interface CaseCall {
  0: 'case';
  1: Expression4;
     2: Expression4;
  3: Expression4;
  length: 4 | 6 | 8 | 10 | 12....
}

interface RGBCall {
  0: 'RGB';
  1: Expression4;
  2: Expression4;
  3: Expression4;
  length: 4;
}

const tests: Expression4[] = [
    10,
  "red",
  true, //error true is not in Expression3
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
  //["case", [">", ...],...] is not in 'string'
  ["**", 2, 31], //error 'number' is not in 'string'
     ["rgb",255,123,64],
  ["rgb",255,123,64, 74], //error 'number' is not in 'string'
]

타입 정보가 더 정밀해졌지만 결과적으로 이전 버전보다 개선되었다고 보기는 어렵습니다.
잘못 사용된 코드에서 오류가 발생하기는 하지만 오류 메시지는 더 난해해졌습니다.

일반적으로 any 같은 매우 추상적인 타입은 정제하는 것이 좋습니다.
그러나 타입이 구체적으로 정제된다고 해서 정확도가 무조건 올라가지는 않습니다.
타입에 의존하기 시작하면 부정확함으로 인해 발생하는 문제는 더 커질 것입니다.

####⭐️ 요약
📍 타입 안정성에서 불쾌한 골짜기는 피해야 합니다.타입이 없는 것보다 잘못된 게 더 나쁩니다.
📍 정확하게 타입을 모델링할 수 없다면, 부정확하게 모델링하지 말아야 합니다. 또한 any와 unknown을 구별해서 사용해야 합니다.
📍 타입 정보를 구체적으로 만들수록 오류 메시지와 자동 완성 기능에 주의를 기울여야 합니다.

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

타입을 잘 설계하면 어떤 이점이 있는지, 반대로 설계가 잘못되면 무엇이 잘못될 수 있는지에 대해서 다루었습니다.

파일 형식, API, 명세 등 우리가 다루는 타입 중 몇 개는 프로젝트 외부에서 비롯된 것입니다.
이러한 경우는 타입을 직접 장성하지 않고 자동으로 생성할 수 있습니다. 여기서 핵심은, 예시 데이터가 아니라 명세를 참고해 타입을 생성하는 것입니다.

"item 31" 에서 FEature의 경계 상자를 계산하는 calculateBoundingBox 함수를 사용했습니다. 실제 구현은 다음과 같은 모습입니다.

function calculateBoundingBox(f: Feature): BoundingBox | null {
  let box: BoundingBox | null = null;
  
  const helper = (coords: any[]) => {
    // ...
  }
  
  const {geometry} = f;
  if (geometry) {
    helper(geometry.coordinates);
  }
  
  return box;
}

Feature 타입은 명시적으로 정의된 적이 없습니다. "item 31"에 등장한 focusOnFeature 함수 예제를 사용하여 작성해 볼 수 있습니다. 그러나 공식 GeoJSOn 명세를 사용하는 것이 더 낫습니다.

$ npm install --save-dev @types/geojson

GeoJSON 선언을 넣는 순간, 타입스크립트는 오류를 발생시킵니다.

import {Feature} from 'geojson';

function calculateBoundingBox(f: Feature):BoundingBox | null {
  let box: BoundingBox | null = null;
  
  const helper = (coords: any[]) => {
    // ...
  };
  
  const {geometry} = f;
  if (geometry) {
    helper(geometry.coordinates);
    			// ~~~~~~~~~~~~~
    			// 'Geometry' 형식에 'coordinates' 속성이 없습니다.
    			//	'GeometryCollection' 형식에
    			//	'coordinates' 속성이 없습니다.
  }
  return box;
}

geometry에 coordinates 속성이 있다고 가정한 게 문제입니다.
geometry가 geometryCollection 타입인 Feature를 사용해서 calculateBoundingBox를 호출하면 undefined의 0 속성을 읽을 수 없다는 오류를 발생합니다.
이 오류를 고치는 한 가지 방법은 GeometryCollection을 명시적으로 차단하는 것입니다.

const {geometry} = f;
if (geometry) {
  if (geometry.type === 'GeometryCollection') {
    throw new Error('GeometryCollections are not supported.');
  }
  helper(geometry.coordinates); // 정상
}

차단된 GeometryCollection 타입의 경우, 사용자에게 명확한 오류 메시지를 제공합니다.
그러나 GeometryCollection 타입을 차단하기보다는 모든 타입을 지원하는 것이 더 좋은 방법이기 때문에 조건을 분기해서 헬퍼 함수를 호출하면 모든 타입을 지원할 수 있습니다.

const geometryHelper = (g: Geometry) => {
  if (geometry.type === 'GeometryCollection') {
    geometry.geometries.forEach(geometryHelper);
  } else {
    helper(geometry.coordinates); // 정상
  }
}

const {geometry} = f;
if (geometry) {
  geometryHelper(geometry);
}

명세를 기반으로 타입을 작성한다면 현재까지 경험한 데이터뿐만 아니라 사용 가능한 모든 값에 대해서 작동한다는 확신을 가질 수 있습니다.

API 호출에도 비슷한 고려 사항들이 적용됩니다. API의 명세로부터 타입을 생성할 수 있다면 그렇게 하는 것이 좋습니다.특히 GraphQL처럼 자체적으로 타입이 정의된 API에서 잘 동작합니다.

query {
  repository(owner: "Microsoft", name: "TypeScript") {
    createdAt
    description
  }
}

// 결과
{
  "data": {
    "repository": {
      "createdAt": "2014-06-17T15:28:39Z",
      "description":
      	"TypeScript is a superset of JavaScript that compiles to JavaScript."
    }
  }
}

GraphQL의 장점은 특정 쿼리에 대해 타입스크립트 타입을 생성할 수 있다는 것입니다.
GeoJSON 예제와 마찬가지로 GrapthQL을 사용한 방법도 타입에 null이 가능한지 여부를 정확하게 모델링할 수 있습니다.

GraphQL 쿼리를 타입스크립트 타입으로 변환해 주는 많은 도구가 존재합니다. 그중 하나는 Apollo입니다.

####⭐️ 요약
📍 코드의 구석 구석까지 타입 안전성을 얻기 위해 API 또는 데이터 형식에 대한 타입 생성을 고려해야 합니다.
📍 데이터에 드러나지 않는 예외적인 경우들이 문제가 될 수 있기 때문에 데이터보다는 명세로부터 코드를 생성하는 것이 좋습니다.

Item 36. 해당 분야의 용어로 타입 이름 짓기

타입의 이름 짓기 역시 타입 설계에서 중요한 부분입니다.
엄선된 타입, 속성, 변수의 이름은 의도를 명확히 하고 코드와 타입의 추상화 수준을 높여 줍니다.
잘못 선택한 타입 이름은 코드의 의도를 왜곡하고 잘못된 개념을 심어 주게 됩니다.

동물들의 데이터베이스를 구축한다고 가정해 보겠습니다.

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

const leopard: Animal = {
  name: 'Snow Leopard',
  endagered: false,
  habitat: 'tundra',
}

문제점

  • name은 매우 일반적인 용어입니다. 동물의 학명인지 일반적인 명칭인지 알 수 없습니다.
  • edangered 속성이 멸종 위기를 표현하기 위해 boolean 타입을 사용한 것이 이상합니다. 이미 멸종된 동물을 true로 해야 하는지 판단할 수 없습니다.
  • 서식지를 나타내는 habitat 속성은 너무 범위가 넓은 string 타입일 뿐만 아니라 서식지라는 뜻 자체도 불분명하기 때문에 다른 속성들보다도 훨씬 모호합니다.
  • 객체의 변수명이 leopard이지만, name 속성의 값은 'Snow Leopard'입니다. 객체의 이름과 속성의 name이 다른 의도로 사용된 것인지 불분명합니다.

코드의 타입 선언으로 문제점을 해결

interface Animal {
  commonName: string;
  genus: string;
  species: string;
  status: ConservationStatus;
  climates: KoppenClimate[];
}

type ConservationStatus = 'EX' | 'EW' | 'CR' | 'EN' | 'VU' | 'NT' | 'LC';
type KoppenClimate = 'Af' | 'Am' | 'As' | 'Aw' | 'BSh' | 'BSk' | 'BWh' | 'BWk' | 'Cfa' | 'Cfb' | 'Cfc' | 'Csa' | 'Csb' | 'Csc' | 'Cwa' | 'Cwb' | 'Cwc' | 'Dfa' | 'Dfb' | 'Dfc' | 'Dfd' | 'Dsa' | 'Dsb' | 'Dsc' | 'Dwa' | 'Dwb' | 'Dwc' | 'Dwd' | 'EF' | 'ET'

const snowLeopard: Animal = {
  commonName: 'Snow Leopard',
  genus: 'Panthera',
  species: 'Uncia',
  status: 'VU',					// 취약종(vulnerable)
  climates: ['ET', 'EF', 'Dfd'], // 고산대(alpine) 또는 아고산대(subalpine)
}

개선점

  • name은 commonName, genus, species 등 더 구체적인 용어로 대체했습니다.
  • endangered는 동물 보호 등급에 대한 IUCN의 표준 분류 체계인 ConservationStatus 타입의 status로 변경되었습니다.
  • habitat은 기후를 뜻하는 climates로 변경되었으며, 쾨펜 기후 분류를 사용해야 합니다.

자체적으로 용어를 만들어 내려고 하지 말고, 해당 분야에 이미 존재하는 용어를 사용해야 합니다.
전문 분야의 용어는 정확하게 사용해야 합니다.

타입, 속성, 변수에 이름을 붙일 때 명심해야 할 3가지 규칙

  • 동일한 의미를 나타낼 때는 같은 용어를 사용해야 합니다.(동의어를 사용할 경우 코드에서는 좋지 않습니다.) 정말로 의미적으로 구분이 되어야 하는 경우에만 다른 용어를 사용해야 합니다.
  • data, info, thing, item, object, entity 같은 모호하고 의미 없는 이름은 피해야 합니다. 만약 entity 라는 용어가 해당 분야에서 특별한 의미를 가진다면 괜찮습니다.
  • 이름을 지을 때는 포함된 내용이나 계산 방식이 아니라 데이터 자체가 무엇인지를 고려해야 합니다. 예를 들어, INodeList보다는 Directory가 더 의미 있는 이름입니다. 좋은 이름은 추상화의 수준을 높이고 의도치 않은 충돌의 위험성을 줄여 줍니다.

####⭐️ 요약
📍 가독성을 높이고, 추상화 수준을 올리기 위해서 해당 분야의 용어를 사용해야 합니다.
📍 같은 의미에 다른 이름을 붙이면 안 됩니다. 특별한 의미가 있을 때만 용어를 구분해야 합니다.

Item 37. 공식 명칭에는 상표를 붙이기

구조적 타이핑의 특성 때문에 가끔 코드가 이상한 결과를 낼 수 있습니다.

interface Vector2D {
  x: number;
  y: number;
}
function calculateNorm(p: Vecotr2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y);
}

calculateNorm({x: 3, y: 4}); // 정상, 결과는 5
const vec3D = {x: 3, y: 4, z: 1};
caculateNorm(vec3D); // 정상! 결과는 동일하게 5

구조적 타이핑 관점에서는 문제가 없기는 하지만, 수학적으로 따지면 2차원 벡터를 사용해야 이치에 맞습니다.

이는 공식 명칭을 사용하면 됩니다. 공식명칭을 사용하는 것은 타입이 아니라 값의 관점에서 Vector2D라고 말하는 것입니다. 공식 명칭 개념을 타입스크립트에서 흉내 내려면 '상표'를 붙이면 됩니다.

interface Vector2D {
  _brand: '2d';
  x: number;
  y: number;
}

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

function calculateNorm(p: Vecotr2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y); // 기존과 동일합니다.
}

calculateNorm(vec2D(3, 4)); // 정상, 결과는 5
const vec3D = {x: 3, y: 4, z: 1};
caculateNorm(vec3D); // '_brand' 속성이 ...형식에 없습니다.

그러나 vec3D 값에 _brand: '2d'를 추가하는 것 같은 악의적인 사용을 막을 수는 없으나 단순한 실수를 방지하기에는 충분합니다.

상표 기법은 타입 시스템에서 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있습니다. 타입 시스템이기 때문에 런타임 오버헤드를 없앨 수 있고 추가 속성을 붙일 수 없는 string이나 number 같은 내장 타입도 상표화할 수 있습니다.

상표 기법은 타입 시스템 내에서 표현할 수 없는 수많은 속성들을 모델링하는 데 사용되기도 합니다.
예를 들어, 목록에서 한 요소를 찾기 위해 이진 검색을 하는 경우를 보겠습니다.

function binarySearch<T>(xs: T[], x: T): boolean {
  let low = 0, high = xs.length - 1;
  while (high >= low) {
    const mid = low + Math.floor((high - low) / 2);
    const v = xs[mid];
    if (v === x) return true;
    [low, high] = x > v ? [mid + 1, high] : [low, mid - 1];
  }
  return false;
}

목록이 이미 정렬되어 있다면 문제가 없습니다.하지만 목록이 정렬되어 있지 않다면 잘못된 결과가 나옵니다. 타입스크립트 타입 시스템에서는 목록이 정렬되어 있다는 의도를 표현하기 어렵습니다.

type SortedList<T> = T[] & {_brand: 'sorted'};
function isSorted<T>(xs: T[]): xs is SortedList<T> {
  for (let i = 1; i < xs.length; i++) {
    if (xs[i] < xs[i - 1]) {
      return false;
    }
  }
  return true;
}

function binarySearch<T>(xs: SortedList<T>, x: T): bloolean {
  // ...
}

isSorted에서 목록 전체를 루프 도는 것이 효율적인 방법은 아니지만 적어도 안전성은 확보할 수 있습니다.

number 타입에도 상표를 붙일 수 있습니다. 예를 들어, 단위를 붙여 보겠습니다.

type Meters = number & {_brand: 'meters'};
type Seconds = number & {_brand: 'seconds'};

const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;

const oneKm = meters(1000); // 타입이 Meters
const oneMin = seconds(60); // 타입이 Seconds

// number 타입에 상표를 붙여도 산술 연산 후에는 상표가 없어지기 때문에 실제로 사용하기에는 무리가 있습니다.
const tenKm = oneKm * 10; // 타입이 number
const v = oneKm / oneMin; // 타입이 number

####⭐️ 요약
📍 타입스크립트는 구조적 타이핑(덕 타이핑)을 사용하기 때문에, 값을 세밀하게 구분하지 못하는 경우가 있습니다. 값을 구분하기 위해 공식 명칭이 필요하다면 상표를 붙이는 것을 고려해야 합니다.
📍 상표 기법은 타입 시스템에서 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있습니다.

profile
프론트엔드 개발자

0개의 댓글