6장 고급 타입

2021. 07. 23

6.1 타입 간의 관계

6.1.1 서브타입과 슈퍼타입

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/4681c411-91b3-4c16-aac1-56c7dd6b8d19/Untitled.png

SubType

두 개의 타입 A와 B가 있고, B가 A의 서브타입이면, A가 필요한 곳에는 어디든 B를 안전하게 사용할 수 있다.

ex)

  • 배열은 객체의 서브타입
  • 튜플은 배열의 서브타입
  • 모든 것은 Any의 서브타입
  • never는 모든 것의 서브타입

이는 곧

  • 객체를 사용해야 하는 곳에 배열도 사용할 수 있다
  • 배열을 사용해야 하는 곳에 튜플도 사용할 수 있다.
  • Any를 사용하는 곳에 모든 것을 사용할 수 있다.
  • 어디에나 never를 사용할 수 있다.

SuperType

두 개의 타입 A,B가 있고 B가 A의 슈퍼타입이라면 B가 필요한 곳에 어디든 A를 안전하게 쓸 수 있다.

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/17fa0110-b192-4052-9ee6-7dcc18b64bc7/Untitled.png

  • 배열은 튜플의 슈퍼타입이다.
  • 객체는 배열의 슈퍼타입이다.
  • any는 모든것의 슈퍼타입이다.
  • never는 누구의 슈퍼타입도 아니다.

6.1.2 가변성

<:T

타입이 T 또는 T의 서브타입

>:T

타입이 T또는 T의 슈퍼타입

  • 불변(invariance)

    • 정확히T를 원함
  • 공변(covariance)

    • <:T를 원함
  • 반변(contravariance)

    • :T를 원함

  • 양변(bivariance)

    • <:T 또는 >:T를 원함

보통 A라는 타입이 B라는 타입의 서브타입인지 아닌지 쉽게 판단할 수 있다. 그러나 매개변수화된 타입(제네릭)등 복합타입에서는 이런 문제가 더 복잡해진다.

ex)

의 서브타입 규칙을 추론하기 어려워 명확하게 이게 어떤 타입인지 알 수 없다.

형태와 배열 가변성

type ExistingUser = {
  id: number
  name: string
}

type NewUser = {
  name: string
}

function deleteUser(user: { id?: number; name: string }) {
  delete user.id
}

const user: ExistingUser = {
  id: 1,
  name: "john doe",
}

deleteUser(user)
console.info(user.id) // undefined

이런 코드를 작성했을 경우, tsc는 user의 id가 삭제되었는지 모르고, 여전히 user.id를 number로 인식하고 있다.

이처럼, 어떤 객체를 슈퍼타입을 기대하는 곳에 사용하는 것은 안전하지 않을 수 있다. 그런데 왜 타입스크립트는 이를 허용할까 ?

전반적으로, 타입스크립트는 완벽한 안전성을 추구하도록 설계되지는 않았다.

  • 완벽함보다는 실제 실수를 잡는 것
  • 쉬운 사용

이라는 두 가지 목표를 균형있게 달성하는 것이 타입스크립트 타입 시스템의 목표이다.

안전성이 보장되지 않는 이 특정한 상황은 실용적인 면에서 타당성이 있으나, 프로퍼티 삭제와 같은 파괴적인 갱신은 실무에서 비교적 드물게 일어나므로 tsc는 이를 적극 제지하지 않고 슈퍼타입이 필요한 곳에 객체를 할당할 수 있도록 허용한다.

반대로 서브타입이 필요한 곳에 할당은 이와 좀 다르다.

type LegacyUser = {
  id?: number | string
  name: string
}

const legacy: LegacyUser = {
  id: "12312",
  name: "Jane Doe",
}

const legacy2: LegacyUser = {
  id: 12312,
  name: "James Doe",
}

deleteUser(legacy)
deleteUser(legacy2)
/*
Argument of type 'LegacyUser' is not assignable to parameter of type 
	'{ id?: number | undefined; name: string; }'.
Types of property 'id' are incompatible.
Type 'string | number | undefined' is not assignable to type 
	'number | undefined'.
Type 'string' is not assignable to type 'number | undefined'.ts(2345)
*/

슈퍼타입의 id는 string | number | undefined인데 반해, deleteUser는 id가 number | undefined 만 처리 가능하기 때문이다.

타입스크립트에서 어떤 형태(B)를 요구할 때 건낼 수 있는 타입(A)은, A <: B여야 하며, A >:B 일경우 A는 전달 할 수 없다.

타입과 관련해 타입스크립트 형태(객체와 클래스)는 그들의 프로퍼티 타입에 공변(<:T)한다고 말한다. 즉, 객체 B에 할당할 수 있는 객체 A가 있다면 객체 A의 각프로퍼티 <: B의 대응 프로퍼티 라는 조건을 만족해야 한다.

interface A {
  name: string
  id: string
  children: object
}

interface B extends A {
  name: "John Doe" | "Jane Doe"
  id: "james Doe" | "Kill"
  children: number[]
}

const toStr = (obj: A) => {
  console.log(obj.id, obj.name, ...Object.values(obj))
}

const toStr2 = (obj: B) => {
  console.log(obj.id, obj.name, ...Object.values(obj))
}

const a: B = {
  name: "Jane Doe",
  id: "james Doe",
  children: [1, 2, 3, 4],
}

const b: A = {
  name: "Jane Doe",
  id: "james Doe",
  children: [1, 2, 3, 4],
}

toStr(a)
toStr2(b) // error

/*
Argument of type 'A' is not assignable to parameter of type 'B'.
  Types of property 'name' are incompatible.
    Type 'string' is not assignable to type '"John Doe" | "Jane Doe"'.ts(2345)
*/

TSC에서 모든 복합 타입의 멤버(객체, 클래스, 배열, 함수, 반환 타입)은 공변(<:T) 이며, 함수 매개변수 타입만 예외적으로 반변(>:T) 이다.

함수 가변성

함수 A가 함수 B와 같거나 적은 수의 매개변수를 가지며 다음을 만족하면, A는 B의 서브타입이다.

  1. A의 this타입을 따로 지정하지 않거나, A의 this 타입 >: B의 this 타입
  2. A의 각 매개변수 >: B의 corresponding parameter
  3. A의 return type <: B 의 타입
function user(a: string, b: string, c: number): void {
  console.log(a, b, c)
}

function user2(a: string, b: string, c: number, d: boolean): void {
  console.log(a, b, c, d)
}

function user3(a: string, b: string): void {
  console.log(a, b)
}

function po(fn: (a: string, b: string, c: number) => void, num: number) {}

po(user, 3)
po(user2, 2)
po(user3, 2)
class Animal {}

class Bird extends Animal {
  chirp() {}
}

class Crow extends Bird {
  caw() {}
}

function chirp(bird: Bird) {
  bird.chirp()
  return bird
}

chirp(new Animal()) // error 발생
chirp(new Bird())
chirp(new Crow())

function clone(f: (b: Bird) => Bird): void {}
function birdToBird(b: Bird): Bird {
  return b
}

clone(birdToBird)

function birdToCrow(d: Bird): Crow {
  console.log(d)
  return new Crow()
}

clone(birdToCrow)

function birdToAnimal(d: Bird): Animal {
  console.log(d)
  return new Animal()
}

clone(birdToAnimal) // 에러 발생

function animalToBird(a: Animal): Bird {
  console.log(a)
  return new Bird()
}

function crowToBird(a: Crow): Bird {
  console.log(a)
  return new Bird()
}

clone(animalToBird) // 에러발생
clone(crowToBird)

6.1.3 할당성(assignability)

A라는 타입을 다른 B 타입이 필요한 곳에 사용할 수 있는지를 결정하는 타입스크립트의 규칙

“A를 B에 할당할 수 있는가?” 라는 요청이 들어왔을 때 TSC는 몇가지 규칙에 따라 처리한다.

열거형이 아닌 배열, 불, 숫자, 객체, 함수, 클래스, 인스턴스, 문자열, 리터럴 타입은 다음의 규칙으로 A를 B에 할당할 수 있는지 결정한다.

  1. A <: B

    A가 B의 서브타입이라면 B가 필요한 곳에 A를 사용할 수 있다.

  2. A는 any

    예외를 설명하며, JS 코드와 상호 운용시에 유용하다

enum이나 const enum 키워드로 만드는 열거형 타입에는 다음 조건 중 하나를 만들어야 A 타입을 열거형 B에 할당 할 수 있다.

  1. A는 enum B의 멤버다
  2. B는 number 타입의 멤버를 최소 한 개 이상 가지고 있으며, A는 number여야 한다.

6.1.4 타입 넓히기(type widening)

타입 추론이 어떻게 동작하는지 이해하는데 필요한 핵심 개념.

타입스크립트는 타입을 정밀하게 추론하기 보다는 일반적으로 추론한다.

let, var

변경 가능한 변수로 선언하면, 그 변수의 타입이 리터럴 값에서 리터럴 값이 속한 기본 타입으로 넓혀진다.

그러나, 타입을 명시하면 타입이 넓어지지 않도록 막을 수 있다.

const

타입이 리터럴 값으로 정해지게 된다.

null, undefined로 초기화

any 타입으로 넓혀진다.

type const

  • type widening을 막아주는 특별 타입으로, type assertion으로 활용된다.
  • as const 를 사용하면 type widening이 중지되고, 멤버들도 readonly가 된다.

초과 프로퍼티 확인(excess property checking)

  • tsc가 한 객체 타입을 다른 객체 타입에 할당할 수 있는지 확인할 때에도 type widening을 이용한다.
  • 객체 리터럴 타입 T를 다른 타입 U에 할당하는 상황에서, T가 U에 존재하지 않는 프로퍼티를 가지고 있으면 TSC는 이를 에러로 처리한다.
type Options = {
  baseURL: string
  cacheSize?: number
  tier: "prod" | "dev"
}

class API {
  constructor(private opitons: Options) {}
}

new API({
  baseURL: "https://api.mysite.com",
  tier: "prod",
})

new API({
  baseURL: "https://api.mysite.com",
  tierr: "prod",
}) // error
  • {baseURL: string, cacheSize?: number, tier?: 'prod' | 'dev'} 타입이 필요하다
  • {baseURL: string, tierr: string} 타입을 전달했다.
  • 필요 타입의 서브타입을 전달했고, TSC는 이를 에러로 판단했다.

Fresh object literal type T를 다른 타입 U에 할당하려는 상황에서 T가 U에는 존재하지 않는 프로퍼티를 가지고 있다면 TSC는 이를 에러로 처리한다.

여기서 Fresh object literal type은 TSC가 객체 리터럴로부터 추론한 타입을 가리킨다. 객체 리터럴이 타입 어서션을 사용하거나 변수로 할당되면, fresh object literal type은 일반 객체 타입으로 넓혀지면서 Fresh는 사라진다.

6.1.5 정제(refinement)

TSC는 symbolic execut

zion의 일종인 flow 기반 타입 추론을 수행한다. type checker는 typeof, instance of 등 타입 질의 뿐 아니라, 제어 흐름 문장(if, ||, switch) 도 고려해서 타입을 정제한다.

type Unit = "cm" | "px" | "%"

let units: Unit[] = ["cm", "px", "%"]

function parseUnit(value: string): Unit | null {
  for (let i = 0; i < units.length; i++) {
    if (value.endsWith(units[i])) {
      return units[i]
    }
  }
  return null
}

type Width = {
  unit: Unit
  value: number
}

function parseWidth(width: number | string | null | undefined): Width | null {
  if (width === null || typeof width === "undefined") {
    return null
  }

  if (typeof width === "number") {
    return {
      unit: "px",
      value: width,
    }
  }

  const unit = parseUnit(width)
  if (!unit) {
    return null
  }

  return { unit, value: parseFloat(width) }
}

Union Type

type UserTextEvent = {
  value: string
  target: HTMLInputElement
}

type UserMouseEvent = {
  value: [number, number]
  target: HTMLElement
}

type UserEvent = UserTextEvent | UserMouseEvent

function handle(event: UserEvent) {
  if (typeof event.value === "string") {
    event.value // string
    event.target // UserTextEvent | UserMouseEvent;
    return
  }
  event.value // [number, number];
  event.target // UserTextEvent | UserMouseEvent;
  return
}

[event.target](http://event.target) 에서 타입이 원하는대로 동작하지 않는다. 그 이유는 handle이 UserEvent 타입의 매개변수를 받는 다는 것은 UserTextEvent나 UserMouseEvent 뿐만 아니라UserTextEvent | UserTextEvent 타입의 변수도 전달 할 수도 있다.

유니온의 멤버가 서로 중복될 수 있으므로, TSC는 유니온의 어떤 타입에 해당하는지 알 수 있게끔 더 신뢰할 만한 정보를 제공 해야 한다.

이 정보를 제공하는 방법으로써, Union Type에 사용되는 각각의 타입에 literal type tag 를 사용할 수 있다. 태그를 사용하는 좋은 방법은 다음과 같다.

  1. 유니온 타입에서 사용되는 각각의 타입의 필드들을 동일한 위치에 두는게 좋다. object의 경우 object의 property의 위치 및 프로퍼티 명을 동일하게 하고, 튜플의 경우 동일한 인덱스에 튜플 태그를 두는게 좋다. 일반적으로 tagged union 은 object type에 주로 쓰인다.
  2. 리터럴 타입으로 타이핑 된 것. 다양한 리터럴 타입을 혼합하고 매치할 수 있지만, 한가지 타입만 사용하는 것이 바람직 하다. 보통 문자열 리터럴을 사용한다.
  3. 제네릭이 아니다. 태그는 제네릭 타입 인수를 받지 않아야 한다.
  4. 상호 배타적이다
type UserTextEvent = {
  type: "TextEvent"
  value: string
  target: HTMLInputElement
}

type UserMouseEvent = {
  type: "MouseEvent"
  value: string
  target: HTMLElement
}

type UserEvent = UserTextEvent | UserMouseEvent

function handle(event: UserEvent) {
  if (event.type === "TextEvent") {
    event.target // HTMLInputElement
    return
  }
  event.target // HTMLElement
}

6.2 종합성(totality)

필요한 모든 상황을 제대로 처리했는지 type checker가 검사하는 기능. 타입 스크립트는 다양한 상황의 모든 가능성을 확인하며, 빠진 상황이 있다면 이를 경고한다.

type Weekday = "MON" | "TUE" | "WED" | "THU" | "FRI"
type Day = Weekday | "SAT" | "SUN"

function getNextDay(w: Weekday): Day {
  /*
Function lacks ending return statement and return type does not include 
'undefined'.ts(2366)
type Day = Weekday | "SAT" | "SUN"
*/
  switch (w) {
    case "MON":
      return "THU"
  }
}

function isBig(n: number) {
  /*
Not all code paths return a value.ts(7030)
function isBig(n: number): true | undefined
*/
  if (n >= 100) {
    return true
  }
}

6.3 고급 객체 타입

6.3.1 key in

type APIResponse = {
  user: {
    userId: string
    friendList: {
      count: number
      friends: {
        firstName: string
        lastName: string
      }[]
    }
  }
}

function getAPIResponse(): Promise<APIResponse> {
  //....
}

function renderFriendList(friendList: unknown) {
  //
}
let response = await getAPIResponse()
renderFriendList(response.user.friendList)

// friendList type ?

// #1. type All
type FriendList = {
  count: number
  friends: {
    firstName: string
    lastName: string
  }[]
}

type APIResponse = {
  user: {
    userId: string
    friendList: FriendList
  }
}

function renderFriendList(friendList: FriendList) {
  //...
}

// #2. use key in

type FriendList = APIResponse["user"]["friendList"]

6.3.2 keyof

객체의 모든 키를 문자열 리터럴 타입 유니온으로 얻을 수 있다.

type ResponseKeys = keyof APIResponse // user: string
type UserKeys = keyof APIResponse["user"] // 'userId' | 'friendList'
type FriendListKeys = keyof APIResponse["user"]["friendList"]
// 'count' | 'friends'

function get<O extends object, K extends keyof O>(o: O, k: K): O[K] {
  return o[k]
}

type Get = {
  <O extends object, K1 extends keyof O>(o: O, k1: K1): O[K1]
  <O extends object, K1 extends keyof O, K2 extends keyof O[K1]>(
    o: O,
    k1: K1,
    k2: K2
  ): O[K1][K2]
  <
    O extends object,
    K1 extends keyof O,
    K2 extends keyof O[K1],
    K3 extends keyof O[K1][K2]
  >(
    o: O,
    k1: K1,
    k2: K2,
    k3: K3
  ): O[K1][K2][K3]
}

6.3.3 Record<Keys, Type>

타입 Type의 프로퍼티 키의 집합으로 타입을 생성한다. 이 유틸리티는 타입의 프로퍼티를 다른 타입에 매핑 시키는데 사용될 수 있다.

  • Record는 object type을 가지게 되는데, property key의 타입은 Keys 가 되고, 프로퍼티의 밸류 타입은 Type 이 된다.
  • keyof Record<K, T> 는 K와 같으며, Record<K, T>[K] 는 T와 같다.
type Weekday = "Mon" | "Tue" | "Wed" | "Thu" | "Fri"
type Day = Weekday | "Sat" | "Sun"

const nextDay: Record<Weekday, Day> = {
  Sat: "Tue",
}

interface PageInfo {
  title: string
}

type Page = "home" | "about" | "contact"

const nav: Record<Page, PageInfo> = {
  about: { title: "about" },
  contact: { title: "contact" },
  home: { title: "home" },
}

nav.about

6.3.4 Mapped Type

Mapped Type은 TSC의 고유한 기능으로, 인덱스 시그니처와 동일하게 하나의 객체에 하나의 매핑된 타입을 가질 수 있게 한다.

type Weekday = "Mon" | "Tue" | "Wed" | "Thu" | "Fri"
type Day = Weekday | "Sat" | "Sun"

let nextDay: { [K in Weekday]: Day } = {
  Mon: "Tue",
} /*
	Tue, Wed, Thu, Fri를 포함하지 않음
*/

type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean
}

type FeatureFlags = {
  darkMode: () => void
  newUserProfile: () => void
}

type FeatureOptions1 = OptionsFlags<FeatureFlags>

type FeatureOptions2 = {
  darkMode: boolean
  newUserProfile: boolean
}

type Record<K extends keyof any, T> = {
  [P in K]: T
}
type Account = {
  id: number
  isEmployee: boolean
  notes: string[]
}

type OptionalAccount = {
  [K in keyof Account]?: Account[K]
}

type OptionalAccount2 = {
  [K in keyof Account]: Account[K] | null
}

type OptionalAccount3 = {
  readonly [K in keyof Account]: Account[K]
}

type Account2 = {
  -readonly [K in keyof OptionalAccount3]: Account[K]
}

type Account3 = {
  [K in keyof OptionalAccount]-?: Account[K]
}

내장된 매핑타입

  • Record<Keys, Values>
  • Partial

    Object의 모든 필드를 선택형으로 표시

  • Required

    Object의 모든 필드를 필수형으로 표시

  • Readonly

    Object의 모든 필드를 읽기 전용으로 표시

  • Pick<Object, Keys>

    주어진 Keys에 대응하는 Object의 Subtype 반환

  • 6.3.5 Companion object pattern

    같은 이름공유하는 객체와 클래스를 쌍으로 연결한다.

    type ValidCurrencies = "EUR" | "GBP" | "JPY" | "USD"
    
    // "Small tweak": Type that will be used on
    // the variable that is used as constructor
    type NotExposedCurrency = {
      from: (value: number, unit: ValidCurrencies) => Currency
      DEFAULT: ValidCurrencies
    }
    
    // Here we have the things we would like to export
    
    type Currency = {
      unit: ValidCurrencies
      value: number
    }
    
    // Type Constructor
    // Here is where we use the tweak
    let Currency: NotExposedCurrency = {
      DEFAULT: "USD",
      from(value: number, unit = Currency.DEFAULT): Currency {
        return { unit, value }
      },
    }
    
    export { Currency }
    import { Currency } from "./index"
    
    let amoundDue: Currency = {
      unit: "JPY",
      value: 123,
    }
    
    let otherAmountDue = Currency.from(123, "JPY")
    
    console.log(otherAmountDue)

    6.4 고급 함수 타입들

    6.4.1 튜플의 타입 추론 개선

    tsc에서 튜플을 선언할 때 튜플의 타입에 관대한 편이다. 튜플의 길이, 어떤 위치에 어떤 타입이 들어가는지 무시하고 주어진 상황에서 제공할 수 있는 가장 일반적인 타입으로 튜플의 타입을 추론한다.

    let a = [1, true] // (number | boolean)[]

    때로는 엄격한 추론이 필요한데, a를 고정된 길이의 튜플로 취급하고 싶을 수도 있다. 이럴 때 타입 어서션을 이용하거나, as const 등을 사용해서 만들수 있다.

    타입 어서션을 사용하지 않고, 추론 범위도 좁히지 않은채 튜플을 튜플 타입으로 만드려면 rest parameter의 타입을 추론하는 기법을 이용하면 된다.

    function tuple<T extends unknown[]>(...ts: T): T {
      return ts
    }
    
    // tuple(1, true) // [number, boolean]

    6.4.2 사용자 정의 타입 안전 장치


    © 2024 Doe의 devlog, Built with Vapor blog Theme Gatsby