Skip to content

Latest commit

 

History

History
469 lines (351 loc) · 18.6 KB

File metadata and controls

469 lines (351 loc) · 18.6 KB

Item35, 36, 37

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

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

타입 설계를 잘 해야 한다는 압박감이 있다.

이런 상황에서 타입을 직접 작성하지 않고 자동으로 생성할 수 있다면 매우 유용할 것이다.

예시 데이터가 아니라 명세를 참고해 타입을 생성한다.

예시 데이터를 참고해 타입을 생성하면 눈 앞에 있는 데이터들만 고려하게 되므로 예기치 않은 곳에서 오류가 발생할 수 있다.

명세를 참고해 타입을 생성하면 타입스크립트는 사용자가 실수를 줄일 수 있게 도와준다.

function calculateBoundingBox(f: Feature): BoundingBox | null {
  let box: BoundingBox | null = null

  const helper = (coords: any[]) => {
    // ...
  }

  const { geometry } = f
  if (geometry) { // geometry가 있으면 helper를 호출 
    helper(geometry.coordinates)
  }
  return box
}

Feature 타입은 명시적으로 정의된 적이 없다. Item31의 focusOnFeature 함수예제를 보고 작성할 수 있지만, 이때 공식 GeoJSON 명세를 사용하는 것이 낫다.

다행히도 **DefinitelyTyped**에는 이미 타입스크립트 타입 선언이 존재한다.

$ npm install --save-dev @types/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(좌표) 속성이 있다고 가정한게 문제이다.

    이러한 관계는 점, 선, 다각형을 포함한 많은 도형에서는 맞는 개념이다.

    그러나 GeoJSON은 다양한 도형의 모음인 GeometryCollection일 수도 있다.

    다른 도형 타입들과 다르게 GeometryCollection에는 coordinates 속성이 없다.

geometry가 GeometryCollection 타입인 Feature를 사용해서 calculatedBoundingBox를 호출하면 undefined의 0 속성을 읽을 수 없다는 오류를 발생한다.

  • 이 오류를 고치는 방법은? GeometryCollection을 명시적으로 차단하는 것!

    const { geometry } = f
    if (geometry) {
      if(geometry.type === 'GeometryCollection') {
    		throw new Error('GeometryCollections are not supported.');
    	}
      helper(geometry.coordinates); // 정상
    }
    • 타입스크립트는 타입을 체크하는 방법으로 도형의 타입을 정제할 수 있으므로 정제된 타입에 한해서 geometry.coordinates의 참조를 허용하게 된다.
    • 차단된 GeometryCollection 타입의 경우, 사용자에게 명확한 오류 메시지를 제공한다.

그러나 GeometryCollection 타입을 차단하기보다는 모든 타입을 지원하는 것이 더 좋은 방법이기 때문에 조건을 분기해서 helper 함수를 호출하면 모든 타입을 지원할 수 있다.

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

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

직접 작성한 타입 선언에는 GeometryCollection과 같은 예외 상황이 포함되지 않았을테고 완벽할 수도 없다.

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

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"
		}
	}
}

GraphQL API는 타입스크립트와 비슷한 타입 시스템을 사용하여, 가능한 모든 쿼리와 인터페이스를 명세하는 스키마로 이루어진다. 우리는 이러한 인터페이스를 사용해서 특정 필드를 요청하는 쿼리를 작성한다.

GraphQL의 장점은 특정 쿼리에 대해 타입스크립트 타입을 생성할 수 있다는 것이다.

GeoJSON 예제와 마찬가지로, GraphQL을 사용한 방법도 타입에 null이 가능한지 여부를 정확하게 모델링할 수 있다.

다음 예제는 Github 저장소에서 오픈소스 라이선스를 조회하는 쿼리이다.

query getLicense($owner:String!, $name:String!) {
	repository(owner: $owner, name: $name) {
		description
		licenseInfo {
			spxId
			name
		}
	}
}

$owner$name은 타입이 정의된 GraphQL 변수이다. 타입 문법이 타입스크립트와 매우 비슷하다.

String은 GraphQL의 타입이다. 타입스크립트에서는 string이 된다. (Item10)

그리고 타입스크립트에서 string 타입은 null이 불가능하지만 GraphQL의 String 타입에서는 null이 가능하다. 타입 뒤의 !는 null이 아님을 명시한다.

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

쿼리에서 타입을 생성하려면 GraphQL 스키마가 필요하다.

Apollo는 [api.github.com/graphql](http://api.github.com/graphql) 로부터 스키마를 얻는다. 실행 결과는 아래와 같다.

export interface getLicense_repository_licenseInfo {
  __typename: 'License'
  /** Short identifier specified by <https://spdx.org/licenses> */
  spdxId: string | null
  /** The license full name specified by <https://spdx.org/licenses> */
  name: string
}

export interface getLicense_repository {
  __typename: 'Repository'
  /** The description of the repository. */
  description: string | null
  /** The license associated with the repository */
  licenseInfo: getLicense_repository_licenseInfo | null
}

export interface getLicense {
  /** Lookup a given repository by the owner and repository name. */
  repository: getLicense_repository | null
}

export interface getLicenseVariables {
  owner: string
  name: string
}
  • 쿼리 매개변수(getLicenseVariables)와 응답(getLicense) 모두 인터페이스가 생성되었다.

  • null 가능 여부는 스키마로부터 응답 인터페이스로 변환되었다.

    repository, description, licenseInfo, spdxId 속성은 null이 가능한 반면, name과 쿼리에 사용된 변수들을 그렇지 않다.

  • 편집기에서 확인할 수 있도록 주석은 JSDoc으로 변환되었다.(Item48) 이 주석들은 GraphQL 스키마로부터 생성되었다.

자동으로 생성된 타입정보는 API를 정확히 사용할 수 있도록 도와준다. 쿼리가 바뀐다면 타입도 자동으로 바뀌며 스키마가 바뀐다면 타입도 자동으로 바뀐다. 타입은 단 하나의 원천 정보인 GraphQL 스키마로부터 생성되기 때문에 타입과 실제 값이 항상 일치한다.

만약 명세 정보나 공식 스키마가 없다면 데이터로부터 타입을 생성해야한다. (ex. quicktype 도구) 그러나 생성된 타입이 실제 데이터와 일치하지 않을 수 있다는 점(예외적인 경우 존재가능)을 주의해야한다.


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

컴퓨터 과학에서 어려운 일은 단 두가지 뿐이다. 캐시 무효화와 이름 짓기

  • 필 칼튼 (Phill Karlton)

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

잘못 선택한 타입 이름은 코드의 의도를 왜곡하고 잘못된 개념을 심어주게 된다.

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

const leopard: Animal = {
  name: 'Snow Leopard',
  endangered: false,
  habitat: 'tundra',
}
  • 문제점 4가지

    • name은 매우 일반적인 용어이다. 동물의 학명인지 일반적인 명칭인지 알 수 없다.
    • endangered 속성이 멸종 위기를 표현하기 위해 boolean 타입을 사용한 것이 이상하다. 이미 멸종된 동물을 true로 해야하는지 판단할 수 없다.
    • 서식지를 나타내는 habitat 속성은 너무 범위가 넓은 string 타입(Item33)일 뿐만 아니라 서식지라는 뜻 자체도 불분명하기 때문에 다른 속성들보다도 훨씬 모호하다.
    • 객체의 변수명이 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'
    	...
    const snowLeopard: Animal = {
      commonName: 'Snow Leopard',
      genus: 'Panthera',
      species: 'Uncia',
      status: 'VU', // 취약종 (vulnerable)
      climates: ['ET', 'EF', 'Dfd'], // 고산대(alpine) or 아고산대(subalpine)
    }
  • 개선된 부분

    • namecommonName, genus, species 등 더 구체적인 용어로 대체
    • endangered는 동물 보호 등급에 대한 IUCN의 표준 분류 체계인 ConservationStatus 타입의 status로 변경
    • habitat은 기후를 뜻하는 climates로 변경되었으며, 쾨펜 기후 분류(Koppen climate classification)를 사용

    ⇒ 데이터를 훨씬 명확하게 표현

    ⇒ 정보를 찾기 위해 사람에 의존할 필요가 없음

자체적으로 용어를 만들어 내려고 하지말고, 해당 분야에 이미 존재하는 용어를 사용해야한다.

⇒ 사용자와 소통에 유리하며 타입의 명확성을 올릴 수 있다.

⇒ BUT 전문분야의 용어는 정확하게 사용해야한다. 특정 용어를 다른 의미로 잘못 쓰게 되면, 직접 만들어낸 용어보다 더 혼란을 줄 수 있다.

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

1) 동일한 의미를 나타낼 때는 같은 용어를 사용

정말로 의미적으로 구분이 되어야하는 경우에만 다른 용어를 사용해야한다.

2) data, info, thing, item, object, entity 와 같은 모호하고 의미없는 이름은 피해야한다. 만약 entity라는 용어가 해당 분야에서 특별한 의미를 가진다면 괜찮다. 그러나 귀찮다고 무심코 의미없는 이름을 붙여서는 안된다.

3) 이름을 지을 때는 포함된 내용이나 계산 방식이 아니라 데이터 자체가 무엇인지를 고려해야한다. 예를 들어 INodeList보다는 Directory가 더 의미있는 이름이다. Directory는 구현의 측면이 아니라 개념적인 측면에서 디렉터리를 생각하게 한다. 좋은 이름은 추상화의 수준을 높이고 의도치 않은 충돌의 위험성을 줄여준다.


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

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

interface Vector2D {
  x: number
  y: number
}
function calculateNorm(p: Vector2D) {
  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 }
calculateNorm(vec3D) // 정상, 결과는 동일하게 5

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

calculateNorm 함수가 3차원 벡터를 허용하지 않게 하려면 **공식 명칭(nominal typing)**을 사용하면 된다.

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

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 Math.sqrt(p.x * p.x + p.y * p.y) // Same as before
}

calculateNorm(vec2D(3, 4)) // OK, returns 5
const vec3D = { x: 3, y: 4, z: 1 }
calculateNorm(vec3D)
// ~~~~~ Property '_brand' is missing in type...

상표(_brand)를 사용해서 calculateNorm 함수가 Vector2D 타입만 받는 것을 보장한다.

그러나 vec3D 값에 _brand: ‘2d’라고 추가하는 것 같은 악의적인 사용을 막을 수는 없다.

다만 단순한 실수를 방지하기에는 충분하다.

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

예를 들어, 절대 경로를 사용해 파일 시스템에 접근하는 함수를 가정해 보겠다. 런타임에는 절대 경로(’/’)로 시작하는지 체크하기 쉽지만, 타입 시스템에서는 절대 경로를 판단하기 어렵기 때문에 상표 기법을 사용한다.

  • 절대경로 예제

    type AbsolutePath = string & { _brand: 'abs' }
    function listAbsolutePath(path: AbsolutePath) {
      // ...
    }
    function isAbsolutePath(path: string): path is AbsolutePath {
      return path.startsWith('/')
    }

string 타입이면서 _brand 속성을 가지는 객체를 만들 수는 없다. AbsolutePath는 온전히 타입 시스템의 영역이다. 만약 path 값이 절대 경로와 상대경로 둘 다 될 수 있다면, 타입을 정제해 주는 타입 가드를 사용해서 오류를 방지할 수 있다.

type AbsolutePath = string & { _brand: 'abs' }
function listAbsolutePath(path: AbsolutePath) {
  // ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/')
}
function f(path: string) {
  if (isAbsolutePath(path)) {
    listAbsolutePath(path)
  }
  listAbsolutePath(path)
  // ~~~~ Argument of type 'string' is not assignable
  //      to parameter of type 'AbsolutePath'
}

로직을 분기하는 대신 오류가 발생한 곳에 path as AbsolutePath를 사용해서 오류를 제거할 수도 있지만 단언문은 지양해야한다. 단언문을 쓰지 않고 AbsolutePath 타입을 얻기 위한 유일한 방법은 AbsolutePath 타입을 매개변수로 받거나 타입이 맞는지 체크하는 것 뿐이다.

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

  • 이진탐색 코드 (참고용)

    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): boolean {
  // COMPRESS
  return true
  // END
}

binarySearch를 호출하려면, 정렬되었다는 상표가 붙은 SortedList 타입의 값을 사용하거나 isSorted를 호출하여 정렬되었음을 증명해야한다. 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) // Type is Meters
const oneMin = seconds(60) // Type is Seconds

number 타입에 상표를 붙여도 산술 연산 후에는 상표가 없어지기 때문에 실제로 사용하기에는 무리가 있다.

const tenKm = oneKm * 10 // 타입이 number
const v = oneKm / oneMin // 타입이 number

그러나 코드에 여러 단위가 혼합된 많은 수의 숫자가 들어 있는 경우, 숫자의 단위를 문서화하는 괜찮은 방법일 수 있다.

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