jgjgill

러닝 타입스크립트 CHAPTER 15 - 타입 운영

7 min read
learing-typescript-thumbnail

CHAPTER 15 타입 운영

매핑된 타입

타입스크립트는 다른 타입의 속성을 기반으로 새로운 타입을 생성하는 구문을 제공합니다. 즉, 하나의 타입에서 다른 타입으로 매핑합니다. 매핑된 타입은 다른 타입을 가져와서 해당 타입의 각 속성에 대해 일부 작업을 수행하는 타입입니다.

매핑된 타입은 키 집합의 각 키에 대한 새로운 속성을 만들어 새로운 타입을 생성합니다.

type NewType = {
  [K in OriginalType]: NewProperty
}

매핑된 타입의 일반적인 예시는 유니언 타입에 존재하는 각 문자열 리터럴 키를 가진 객체를 생성하는 것입니다.

type Animals = 'alligator' | 'baboon' | 'cat'

type AnimalCounts = {
  [K in Animals]: number
}
// {
//   alligator: number;
//   baboon: number;
//   cat: number;
// }

타입에서 매핑된 타입

매핑된 타입은 존재하는 타입에 keyof 연산자를 사용해 키를 가져올 수도 있습니다.

interface AnimalVariants {
  alligator: boolean
  baboon: number
  cat: string
}

type AnimalCounts = {
  [K in keyof AnimalVariants]: number
}

// {
//   alligator: number;
//   baboon: number;
//   cat: number;
// }

매핑된 타입과 시그니처

인터페이스 멤버를 함수로 선언하는 방법에는 두 가지가 있습니다.

  • member(): void 같은 메서드 구문: 인터페이스의 멤버가 객체의 멤버로 호출되도록 의도된 함수임을 선언
  • member: () => void 같은 속성 구문: 인터페이스의 멤버가 독립 실행형 함수와 같다고 선언

매핑된 타입은 객체 타입의 메서드와 속성 구문을 구분하지 않습니다. 매핑된 타입은 메서드를 원래 타입의 속성으로 취급합니다.

interface Researcher {
  researchMethod(): void
  researchProperty: () => string
}

type JustProperties<T> = {
  [K in keyof T]: T[K]
}

type ResearcherProperties = JustProperties<Researcher>

// {
//   researchMethod: () => void;
//   researchProperty: () => string;
// }

대부분의 실용적인 타입스크립트 코드에서 메서드와 속성의 차이는 잘 나타나지 않습니다.

제한자 변경

매핑된 타입은 원래 타입의 멤버에 대해 접근 제어 제한자인 readonly?도 변경 가능합니다.

interface Environmentalist {
  area: string
  name: string
}

type ReadonlyEnvironmentalist = {
  readonly [K in keyof Environmentalist]: Environmentalist[K]
}

// {
//   readonly area: string;
//   readonly name: string;
// }

type OptionalReadonlyEnvironmentalist = {
  [K in keyof ReadonlyEnvironmentalist]?: ReadonlyEnvironmentalist[K]
}

// {
//   readonly area?: string | undefined;
//   readonly name?: string | undefined;
// }

새로운 타입의 제한자 앞에 -를 추가해 제한자를 제거할 수도 있습니다. -readonly-?을 사용합니다.

제네릭 매핑된 타입

매핑된 타입은 제네릭과 결합해 단일 타입의 매핑을 다른 타입에서 재사용할 수 있을 때 강력한 힘을 발휘합니다.

type MakeReadonly<T> = {
  readonly [K in keyof T]: T[K]
}

interface Species {
  genus: string
  name: string
}

type ReadonlySpecies = MakeReadonly<Species>

// {
//   readonly genus: string;
//   readonly name: string;
// }

조건부 타입

타입스크립트의 타입 시스템은 이전 타입에 대한 논리적인 검사를 바탕으로 새로운 구성(타입)을 생성합니다. 조건부 타입의 개념은 기존 타입을 바탕을 두 가지 가능한 타입 중 하나로 확인되는 타입입니다.

type CheckStringAgainstNumber = string extends number ? true : false // false

제네릭 조건부 타입

조건부 타입에서도 재사용 가능한 제네틱 타입을 적용할 수 있습니다.

type CheckAgainstNumber<T> = T extends number ? true : false

type CheckString = CheckAgainstNumber<'parakeet'> // false

type CheckNumber = CheckAgainstNumber<1981> // true

타입 분산

조건부 타입은 유니언에 분산됩니다. 결과 타입은 각 구성 요소에 조건부 타입을 적용하는 유니언이 됨을 의미합니다. ConditionalType<T | U>Conditional<T> | Conditional<U>와 같습니다.

type ArrayifyUnlessString<T> = T extends string ? T : T[]

type HalfArrayified = ArrayifyUnlessString<string | number> // string | number[]

유추된 타입

제공된 타입의 멤버에 접근하는 것은 타입의 멤버로 저장된 정보에 대해서는 잘 작동하지만 함수 매개변수 또는 반환 타입과 같은 다른 정보에 대해서는 알 수 없습니다. 조건부 타입은 extends 절에 infer 키워드를 사용해 조건의 임의의 부분에 접근합니다.

type ArrayItems<T> = T extends (infer Item)[] ? Item : T

type StringItem = ArrayItems<string> // string

type StringArrayItem = ArrayItems<string[]> // string

type String2DItem = ArrayItems<string[][]> // string[]

매팽된 조건부 타입

메핑된 타입과 조건부 타입을 함께 사용하여 제네릭 템플릿 타입의 각 멤버에 조건부 로직을 적용할 수 있습니다.

type MakeAllMembersFunctions<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : () => T[K]
}

type MemberFuntions = MakeAllMembersFunctions<{
  alreadyFunction: () => string
  notYetFunction: number
}>

// {
//   alreadyFunction: () => string;
//   notYetFunction: () => number;
// }

never

올바른 위치에 never 타입 애너테이션을 추가하면 타입스크립트가 이전 런타임 예제 코드뿐만 아니라 타입 시스템에서 맞지 않는 코드 경로를 더 공격적으로 탐지합니다.

never와 교차, 유니언 타입

bottom 타입인 never존재할 수 없는 타입이라는 의미를 가지고 있습니다. never가 교차 타입(&)과 유니언 타입(|)을 함께 사용하면 흥미롭게 작동합니다.

  • 교차 타입(&)에 있는 never는 교차 타입을 never로 만듭니다.
  • 유니언 타입(|)에 있는 never는 무시됩니다.
type NeverIntersection = never & string // never

type NeverUnion = never | string // string

// 값 필터링

never와 조건부 타입

never는 유니언에서 무시되기 때문에 유니언 타입에서 제네릭 조건부의 결과는 never가 아닌 것이 됩니다.

type OnlyStrings<T> = T extends string ? T : never

type RedOrBlue = OnlyStrings<'red' | 'blue' | 0 | false> // 'red' | 'blue'

never와 매핑된 타입

유니언에서 never의 동작은 매핑된 타입에서 멤버를 필터링할 때도 유용합니다.

  • 유니언에서 never는 무시됩니다.
  • 매핑된 타입은 타입의 멤버를 매핑할 수 있습니다.
  • 조건부 타입은 조건이 충족되는 경우 타입을 never로 변환하는데 사용할 수 있습니다.
type OnlyStringProperties<T> = {
  [K in keyof T]: T[K] extends string ? K : never
}[keyof T]

interface AllEventData {
  participants: string[]
  location: string
  name: string
  year: number
}

type OnlyStringEventData = OnlyStringProperties<AllEventData>

템플릿 리터럴 타입

문자열 값을 입력하기 위한 전략으로 두 가지를 제시했습니다.

  • 원시 string 타입: 값이 세상의 모든 문자열이 될 수 있는 경우
  • ''와 'abc' 같은 리터럴 타입: 값이 오직 한 가지 타입만 될 수 있는 경우

그러나 경우에 따라 문자열이 일부 문자열 패턴과 일치함을 나타내고 싶을 수 있습니다. 이때 타입스크립트 구문으로 템플릿 리터럴 타입을 사용할 수 있습니다.

type Greeting = `Hello${string}`
let matches: Greeting = 'Hello, world' // OK

템플릿 리터럴 타입을 더 좁은 문자열 패턴으로 제한하기 위해 포괄적인 string 원시 타입 대신 문자열 리터럴 타입과 유니언을 타입 보간법에 사용할 수 있습니다.

type Brightness = 'dark' | 'light'
type Color = 'blue' | 'red'

type BrightnessAndColor = `${Brightness}-${Color}` // 'dark-blue' | 'dark-red' | 'light-blue' | 'light-red'

let colorOk: BrightnessAndColor = 'dark-blue' // OK

고유 문자열 조작 타입

문자열 타입 작업을 지원하기 위해 타입스크립트는 고유 제네릭 유틸리티 타입을 제공합니다.

  • Uppercase: 문자열 리터럴 타입을 대문자로 변환
  • Lowercase: 문자열 리터럴 타입을 소문자로 변환
  • Capitalize: 문자열 리터럴 타입의 첫 번째 문자를 대문자로 변환
  • Uncapitalize: 문자열 리터럴 타입의 첫 번째 문자를 소문자로 변환

템플릿 리터럴 키

템플릿 리터럴 타입은 원시 문자열 타입과 문자열 리터럴 사이의 중간 지점이므로 여전히 문자열입니다. 템플릿 리터럴 타입은 문자열 리터럴을 사용할 수 있는 모든 위치에서 사용 가능합니다.

type DataKey = 'location' | 'name' | 'year'

type ExistenceChecks = {
  [K in `check${Capitalize<DataKey>}`]: () => boolean
}

// {
//   checkLocation: () => boolean;
//   checkName: () => boolean;
//   checkYear: () => boolean;
// }

매핑된 타입 키 다시 매핑하기

타입스크립트는 템플릿 리터럴 타입을 사용해 원래 멤버를 기반으로 매핑된 타입의 멤버에 대한 새로운 키를 생성할 수 있습니다. 매핑된 타입에서 인덱스 시그니처에 대한 템플릿 리터럴 타입 다음에 as 키워드를 배치하면 결과 타입의 키는 템플릿 리터럴 타입과 일치하도록 변경됩니다. 이렇게 하면 매핑된 타입은 원래 값을 계속 참조하면서 각 매핑된 타입 속성에 대한 다른 키를 가질 수 있습니다.

interface DataEntry<T> {
  key: T
  value: string
}

type DataKey = 'location' | 'name' | 'year'

type DataEntryGetters = {
  [K in DataKey as `get${Capitalize<K>}`]: () => DataEntry<K>
}

// {
//     getLocation: () => DataEntry<"location">;
//     getName: () => DataEntry<"name">;
//     getYear: () => DataEntry<"year">;
// }

키를 다시 매핑하는 작업과 다른 타입 운영을 결합해 기존 타입 형태를 기반으로 하는 매핑된 타입을 생성하는 것입니다.

자바스크립트에서 객체 키는 string 또는 symbol이 될 수 있고, symbol 키는 원시 타입이 아니므로 템플릿 리터럴 타입으로 사용할 수 없습니다.

type TurnIntoGettersDirect<T> = {
  [K in keyof T as `get${K}`]: () => T[K]
}

// Type 'K' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
//   Type 'keyof T' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
//     Type 'string | number | symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
//       Type 'symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'.

이러한 제한 사항을 피하기 위해 string과 교차 타입(&)을 사용하여 문자열이 될 수 있는 타입만 사용하도록 강제합니다. string & symbolnever가 되므로 전체 템플릿 문자열은 never가 되고 타입스크립트는 이를 무시하게 됩니다.

const someSymbol = Symbol('')

interface HasStringAndSymbol {
  StringKey: string
  [someSymbol]: number
}

type TurnIntoGetters<T> = {
  [K in keyof T as `get${string & K}`]: () => T[K]
}

type GettersJustString = TurnIntoGetters<HasStringAndSymbol>

// {
//     getStringKey: () => string;
// }

타입 운영과 복잡성

애초에 디버깅은 코드를 작성하는 것보다 두 배나 더 어렵습니다. 따라서 코드를 가능한 한 영리하게 작성하는 사람일지라도, 디버그할 정도로 똑똑하지는 않습니다.

마치며

  • 기존 타입을 새로운 타입으로 변환하기 위해 매핑된 타입 사용하기
  • 조건부 타입을 사용해서 타입 운영에 로직 도입하기
  • 교차, 유니언, 조건부 타입, 매핑된 타입과 never가 상호작용하는 방법 배우기
  • 템플릿 리터럴 타입을 사용해서 문자열 타입의 패턴 나타내기
  • 타입 키를 수정하기 위해 템플릿 리터럴 타입과 매핑된 타입 결합하기
@2023 powered by jgjgill