Skip to content

Learning Typescript Chapter 15

Posted on:July 2, 2023 at 04:49 AM

15 ) 타입 운영


이번장에서 사용되게 될 개념은 제네릭과 마찬가지로 과도하게 사용되면 읽기 어려울 수 있으니 유념하자.

매핑된 타입


타입스크립트는 다른 타입의 속성을 기반으로 새로운 타입을 생성하는 구문을 제공하는데, 이가 하나의 타입에서 다른 타입으로 매핑 ( Mapping ) 한다. 타입스크랍트의 매핑된 타입 ( Mapped type ) 은 다른 타입을 가져와서 해당 타입의 각 속성에 대해 일부 작업을 수행하는 타입이다.

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

인덱스 시그니처와 유사한 구문을 사용하지만, [ i : string ] 과 같이 : 를 사용한 정적 키 타입을 사용하는 대신

[ K in OriginalType ] 과 같이 in 을 사용해 다른 타입으로 부터 계산된 타입을 사용한다.

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

type AnimalCounts = {
  [K in Animals]: number;
};

// 다음과 같음

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

존재하는 유니언 리터럴을 기반으로 하는 매핑된 타입은 큰 인터페이스를 ㄱ선언하는 공간을 절약하는 편리한 방법이며, 타입은 다른 타입에 대해 작동하고, 멤버에서 제한자를 추가하거나 제거할 수 있을때 가장 유용해진다.


타입에서 매핑된 타입

일반적으로 매핑된 타입은 존재하는 타입에 keyof 연산자를 사용해 키를 가져오는 방식으로 작동한다.

존재하는 타입의 키를 매핑하도록 타입에 지시하면 새로운 타입으로 매핑한다.

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

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

// 다음과 같음

// 새로운 타입으로 매핑 되었음.
// 원래 타입의 멤버에서 원래의 key 값을 사용하였음.

// type AnimalCounts = {
//     alligator: number;
//     baboon: number;
//     cat: number;
// }

이전 스니펫에서 K 로 명명된 keyof 에 매핑된 새로운 타입 키는 원래 타입의 키로 알려져 있다.

즉, 각 매핑된 타입 멤버 값은 동일한 키 아래에서 원래 타입의 해당 멤버 값을 참조할 수 있다.

interface BirdVariants {
  dove: string;
  eagle: boolean;
}

type NullableBirdVariants = {
  // BirdVariants 의 타입을 매핑해 유니온 null 을 추가해 nullable 하게 만들어줌.

  [K in keyof BirdVariants]: BirdVariants[K] | null;
};

// 다음과 같음

type NullableBirdVariants = {
  dove: string | null;
  eagle: boolean | null;
};

각 필드를 원본타입에서 임이의 수의 다른 방법으로 어렵게 복사하는 대신, 매핑된 타입은 멤버 집합을 한 번 정의하고 필요한 만큼 여러번 새로운 버전을 다시 생성할 수 있다.

매핑된 타입과 시그니처


타입스크립트가 인터페이스 멤버를 함수로 선언하는 두가지 방법

하지만, 매핑된 타입은 객체 타입의 메서드와 속성 구문을 구분하지 않고, 메서드를 원래 타입의 속성으로 구분한다.

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

// 제네릭 T 에 객체 형태가 들어갈 것을 예상 가능.
type JustProperties<T> = {
  [K in keyof T]: T[K];
  // 		key : object[key]
};

type ResearcherProperties = JustProperties<Researcher>;

// 다음과 같음

type ResearcherProperties = {
  researchMethod: () => void;
  researchProperty: () => void;
};

대부분의 실용적인 타입스크립트 코드에서 메서드와 속성의 차이는 잘 나타나지 않고, 클래스 타입을 갖는 매핑된 타입을 실제로 사용하는 경우는 매우 드문 일이다.


제한자 변경

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

interface Environmentalist {
  area: string;
  name: string;
}

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

//. 다음과 같음

type ReadonlyEnvironmentalist = {
  readonly area: string;
  readonly name: string;
};

// Mapped optionally

type OptionalEnvironmentalist = {
  [K in keyof Environmentalist]?: Environmentalist[K];
};

// 다음과 같음

type OptionalEnvironmentalist = {
  area?: string | undefined;
  name?: string | undefined;
};

// Mapped readonly and optionally

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

// 다음과 같음

type OptionalReadonlyEnvironmentalist = {
  readonly area?: string | undefined;
  readonly name?: string | undefined;
};

새로운 타입의 제한자 앞에 - 를 추가해 제한자를 제거할 수도 있다.

readonly?: 를 작성하는 대신 -readonly-?: 를 사용한다.

interface Conservationist {
  name: string;
  catchphrase?: string;
  readonly born: number;
  readonly died?: number;
}

// readonly 제한자 제거

type WritableConservationist = {
  -readonly [K in keyof Conservationist]: Conservationist[K];
};

// 다음과 같음

type WritableConservationist = {
  name: string;
  catchphrase?: string | undefined;
  born: number;
  died?: number | undefined;
};

// ? 옵셔널 제거
type RequiredConservationist = {
  [K in keyof Conservationist]-?: Conservationist[K];
};

// 다음과 같음

type RequiredConservationist = {
  name: string;
  catchphrase: string;
  readonly born: number;
  readonly died: number;
};

// ? 옵셔널과 readonly 제한자 제거

type RequiredWritableConservationist = {
  -readonly [K in keyof Conservationist]-?: Conservationist[K];
};

// 다음과 같음

type RequiredWritableConservationist = {
  name: string;
  catchphrase: string;
  born: number;
  died: number;
};

제네릭 매핑된 타입

매핑된 타입은 제네릭과 결합해 단일 타입의 매핑을 다른 타입에서 재사용할 수 있을 때 완전한다.

매핑된 타입은 매핑된 타입 자첼의 타입 매개변수를 모함해 keyof 로 해당 스코프에 있는 모든 타입 이름에 접근할 수 있다.

제네릭 매핑된 타입은 데이터가 애플리케이션을 통해 흐를때 데이터가 어떻게 변형되는지 나타낼때 유용하다.

예를들어, 애플리케이션 영역이 기존 타입의 값을 가져올 수 는 있지만, 데이터를 수정하는 것은 허용하지 않는 것이 좋다.

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

interface Species {
  genus: string;
  name: string;
}

type ReadonlySpecies = MakeReadonly<Species>;

// 다음과 같음

type ReadonlySpecies = {
  readonly genus: string;
  readonly name: string;
};

개발자들이 일반적으로 표현해야 하는 또 다른 변환은 임의의 수의 인터페이스를 받고, 그 인터페이스의 완전히 채워진 인스턴스를 반환하는 함수이다.

interface GenusData {
  family: string;
  name: string;
}

type MakeOptional<T> = {
  [K in keyof T]?: T[K];
};

type OptionalGenustData = MakeOptional<GenusData>;

// 다음과 같음

// type OptionalGenustData = {
//     family?: string | undefined;
//     name?: string | undefined;
// }

function createGenusData(overrides?: MakeOptional<GenusData>): GenusData {
  return {
    family: 'unknown',
    name: 'unknown',
    ...overrides,
  };
}

function createGenusDataPartially(overrides?: Partial<GenusData>): GenusData {
  return {
    family: 'unknown',
    name: 'unknown',
    ...overrides,
  };
}

// 인자로 들어갈 수 있는 객체의 속성이 optional 한지를 보여주는 것 같음
const newGenusData = createGenusData({ name: 'lee' });

const partialGenusData = createGenusDataPartially({ family: '가족' });

console.log(newGenusData);
// 컴파일된 JS 코드가 동일함.

function createGenusData(overrides) {
  return Object.assign({ family: 'unknown', name: 'unknown' }, overrides);
}
function createGenusDataPartially(overrides) {
  return Object.assign({ family: 'unknown', name: 'unknown' }, overrides);
}

타입 스크립트는 제네릭 매핑된 타입을 즉시 사용할 수 있는 Partial<T> 라는 유틸리티 타입을 제공하는데


조건부 타입

타입스크립트 타입 시스템은 논리 프로그래밍 언어 의 한 예이다.

타입스크립트의 타입 시스템은 이전 타입에 대한 논리적인 검사를 바탕으로 새로운 구성을 생성한다.

조건부 타입 ( conditional type )의 개념은 기존 타입을 바탕으로 두 가지 가능한 타입중 하나로 확인되는 개념이다.

LeftType extends RightType ? IfTrue : IfFalse

조건부 타입에서 논리적 검사는 항상 extends 의 왼쪽 타입이 오른쪽 타입이 되는지 또는 할당 가능한지 여부에 있다.

// 타입은 false
type CheckStringAgainstNumber = string extends number ? true : false;

각각은 어떤 타입을 취하고 두가지 가능한 결과중 하나를 얻는다.


제네릭 조건부 타입

조건부 타입은 조건부 타입 자체의 타입 매개변수를 포함한 해당 스코프에서 모든 타입 이름을 확인 할 수 있다.

즉, 모든 다른 타입을 기반으로 새로운 타입을 생성하기 위해 재사용 가능한 제네릭 타입을 작성할 수 있다.

type CheckStringAgainstNumber<T> = T extends number ? true : false;

// 타입은 false
type CheckString = CheckStringAgainstNumber<'parakeet'>;

// 타입은 true
type CheckString = CheckStringAgainstNumber<1891>;

// 타입은 true
type CheckString = CheckStringAgainstNumber<number>;
type CallableSettings<T> =
  // 왼쪽 타입이 오른쪽 타입에 할당 가능하거나 |  같냐 를 검증
  T extends () => any ? T : () => T;

// 타입은  () => number[]
// any 타입에 () => number[] 이 할당가능하지 않기 때문에 T 그대로를 내보내 준다.
type GetNumbersSetting = CallableSettings<() => number[]>;

// 타입은 () => string
// any 타입은 top 타입 이므로 string 타입이 any 타입에 할당 가능해 () => T 로 변환되어서 내보내 준다.
type StringSetting = CallableSettings<string>;

조건부 타입은 객체 멤버 검색 구문을 사용해서 제공된 타입의 멤버에 접근할 수 있고, extends 절과 결과 타입에서 그 정보를 사용할 수 있다.

자바스크립트 라이브러리에서 사용하는 패턴 중 조건부 제네릭 타입에도 적합한 한 가지 패턴은 함수에 제공된 옵션 객체를 기반으로 함수의 반환 타입을 변경하는 것이다.

interface QueryOptions {
  throwIfNotFound: boolean;
}

type QueryResult<Options extends QueryOptions> =
  // 인자로 throwIfNotFound 를 옵셔널 하게 받아 value 가 true 이면 string 주어지지 않거나 false 이면 string | undefined
  Options['throwIfNotFound'] extends true ? string : string | undefined;

declare function retrieve<Options extends QueryOptions>(
  key: string,
  optiosn?: Options
): Promise<QueryResult<Options>>;

async function executes() {
  // 타입은 string | undefined
  // throwIfNotFound 가 주어지지 않았기 때문에 string | undefined
  const birute = await retrieve('Biruté Galdikas');

  // 타입은 string | undefined
  // Math.random() > 0.5 에 의해 throwIfNotFound : false 가능성을 내재 하고 있으므로, string | undefined
  const jane = await retrieve('Jane Goodal', {
    throwIfNotFound: Math.random() > 0.5,
  });

  // 타입은 string
  // throwIfNotFound 가 true 이기 때문에 원형의 string 타입
  const dian = await retrieve('Dian Fossey', { throwIfNotFound: true });

  return Promise.all([birute, jane, dian]);
}

executes();

조건부 타입을 제네릭 타입 매개변수와 결합하면 ㄷrtrieve 함수는 프로그램의 제어 흐름을 어떻게 변경할 것인지 타입 시스템에 더 정확히 알릴 수 있다.


타입 분산

조건부 타입은 유니언에 분산 ( distribute ) 된다.

결과 타입은 각 구성 요소 ( 유니언 타입 안의 타입 ) 에 조건부 타입을 적용하는 유니언이 됨을 의미

즉, ConditionalType< T | U > 는 ConditionalType<T> | ConditionalType<U> 와 같다.

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

// 타입은 stirng | number[]
type HalfArrayfied = ArrayifyUnlessString<string | number>;

// 아래와 같이 해석할 수 있음.
type HalfArrayfied =
  | ArrayifyUnlessString<string>
  | ArrayifyUnlessString<number>;

// 첫번째 유니온의 경우에 string 에 할당 가능하므로 그대로 T 를 반환
// 두번째 유니온 number 타입은 string에 할당 가능하지 않으므로 number[] 를 반환

조건부 타입은 전체 유니언 타입이 아니라 유니언의 각 구성 요소에 로직을 적용한다.


유추된 타입

제공도니 타입의 멤버에 접근하는 것은 타입의 멤버로 저장된 정보에 대해서는 잘 작동하지만, 함수 매개변수 또는 반환 타입과 같은 다른 정보에 대해서는 알 수 없다.

조건부 타입은 extends 절에 infer 키워드를 사용해 조건의 임의의 부분에 접근한다.

extends 절에 타입에 대한 infer 키워드와 새 이름을 배치하면 조건부 타입이 true 인 경우에 새로운 타입을 사용함을 알 수 잇다.

// 이 부분은 정확하지 않음.

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

// 타입은 string
// string 타입이 (infer Item)[] 에 충족되지 않아서 T 인 string 타입을 반환
type StringItem = ArrayItems<string>;

// 타입은 string
// string[] 타입은 (infer Item)[]에 충족함으로 Item 타입인 string 을 반환
type StringArrayItem = ArrayItems<string[]>;

// 타입은 string[]
// string[][] 타입은 (infer string[])[] 으로 해석하면 item 은 string[] 이 되어 반환된다.
type String2DItems = ArrayItems<string[][]>;

유추된 타입은 재귀적 조건부 타입을 생성하는데에도 사용할 수 있다.

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

// 타입은 string
// 이전과 마찬가지로 string 이 (infer Item)[] 에 할당가능하지 않으므로 T 인 string 반환
type StringItem = ArrayItemsRecursive<string>;

// 타입은 String
// string[] 는 (infer Item)[] 에 충족되어 재귀적으로 ArrayItemsRecursive<string> 이 제네릭으로 들어가는 것과 같음
// 반환은 T 인 string
type StringArrayItem = ArrayItemsRecursive<string[]>;

// 타입은 string
// 위와 마찬가지로 string[] 와 재귀 반환 값인 T 인 string 이 반환
type String2DItem = ArrayItemsRecursive<string[][]>;

// 타입은 string
// 재귀적으로 배열이 깊어져도 불구하고 결국 T 인 string 을 반환
type StringXDItem = ArrayItemsRecursive<string[][][][][][]>;

제네릭 타입이 재귀적일 수 있는 기능을 통해 여기에서 배열의 요소 타입을 검색하는 것과 같은 변경 사항을 계속 적용할 수 있도록 한다.


매핑된 조건부 타입

매핑된 타입은 기존 타입의 모든 멤버에 변경 사항을 적용하고, 조건부 타입은 하나의 기존 타입에 변경사항을 적용한다.

type MakeAllMemversFunctions<T> = {
  // value 값이 함수로 들어왔을 경우에는 value 함수 그대로 반환
  // 함수에 충족하지 않는 경우는 함수 형태로 변환해서 반환

  [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : () => T[K];
};

type MemberFunctions = MakeAllMemversFunctions<{
  alreadyFunction: () => string;
  notYetFunction: number;
}>;

// 다음과 같음

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

never

객체 에서 neverbottom 타입을 보면 이들은 가능한 값을 가질 수 없고, 접근 할 수 없음을 의미

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


never 와 교차, 유니언 타입

// 타입은 never
type NeverIntersection = never & string;

// 타입은 string
type NeverUnion = never | string;

특히 유니언 타입에서 never 가 무시되는 동작은 조건부 타입과 매핑된 타입에서 값을 필터링 하는데 유용하다.


never 와 조건부 타입

제네릭 조건부 타입은 일반적으로 유니언에서 타입을 필터링 하기 위해 never 를 사용

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

//. 타입은 'red' | 'blue'

// string 타입에 할당 가능하지 않는 타입들은 never 타입에서 필터링된다.
type RedOrBlue = OnlyStrings<'red' | 'blue' | 0 | false>;

never 또한 제네릭 타입에 대한 타입 유틸리티를 만들 때 유추된 조건부 타입과 결합된다.

infer가 있는 타입 추론은 조건부 타입이 true 가 되어야 하므로 false 인 경우를 절대 사용하지 않아야 한다.

이때 never 가 적용 가능하다

// infer 이 들어간 조건부 타입에 never 가 들어감으로 함수의 첫번째 매개변수 타입 추출 가능

type FirstParameter<T extends (...args: any[]) => any> = T extends (
  arg: infer Arg
) => any
  ? Arg
  : never;

// 타입은 string
// 인자로 (arg0 : string) => void 가 충족이 되었기 때문에 그 arg 의 타입인 string 을 반환
type GetsString = FirstParameter<(arg0: string) => void>;

never 와 매핑된 타입

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

세가지 기능을 사용하면 원래 타입의 각 메버를 원래 키 또는 never 타입으로 변경하는 매핑된 타입을 만들 수 있다.

type OnlyStringProperties<T> = {
  // string이 아닌 모든 속성을 필터링 한 다음 [keyof T] 를 반환
  // (value 가 string 인 key 를 반환)
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

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

// 타입은 'location' | 'name'
type EventDataWithNever = AllPropertiesWithNever<AllEventData>;

type AllPropertiesWithNever<T> = {
  // value 값이 string에 할당 가능하지 않는 key 들은 never 타입을 할당
  [K in keyof T]: T[K] extends string ? K : never;
};

// 다음과 같음
type EventDataWithNever = {
  participants: never;
  location: 'location';
  name: 'name';
  year: never;
};

템플릿 리터럴 타입

문자열값을 입력하기 위한 두가지 전략

경우에 따라 일부 문자열이 패턴과 일치함을 나타내고 싶을때 사용한다.

템플릿 리터럴 타임은 템플릿 리터럴 문자열 처럼 보이지만 추정할 수 있는 원시타입 또는 원시 타입 유나언이 있다.

type Greeting = `Hello${string}`;

// Hello 만 충족되면 이후에 어떤 문자열이 와도 충족된다.
let mathces: Greeting = 'Hello, world!';

// Greetings 타입에 충족 되지 않음.
let ourOfOrder: Greeting = 'World! Hello1';

// Greetings 타입에 충족 되지 않음.
let missingAltogether: Greeting = 'Hi';
type Brightness = 'dark' | 'light';
type Color = 'blue' | 'red';

// 가능한 타입은 "dark-blue" | "dark-red" | "light-blue" | "light-red"
type BrightnessAndColor = `${Brightness}-${Color}`;

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

// 할당 가능하지 않음
let colorWrongStart: BrightnessAndColor = 'medium-blue';

// 할당 가능하지 않음
let colorWrongEnd: BrightnessAndColor = 'light-green';

고유 문자열 조작 타입

타입 스크립트는 문자열을 가져와 문자열의 일부 조작을 적용하는 고유 제네릭 유틸리티 타입을 제공한다.

// 타입은 Hello.
type FormalGreeting = Capitalize<'hello.'>;

템플릿 리터럴 키

템플릿 리터럴 타입은 원시 문자열 타입과 문자열 리터럴 사이의 중간 지점이므로 여전히 문자열이다.

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

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

//. 다음과 같음

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

function checkExistence (checks : ExistenceChecks) {
    checks.checkLocation();  // OK
    checks.checkName();  // OK

    ~~checks.checkWrong()~~ // 존재하지 않는 key 값
}

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

매핑된 타입에서 인덱스 시그니처에 대한 템플릿 리터럴 타입 다음에 as 키워드 를 배치하면 결과 타입의 키는 템플릿 리터럴 타입과 일치하도록 변경된다.

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

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

type DataEntryGetters = {
  // get + key 값의 새로운 key 를 가진 () => DataEntry<key> 를 가진 객체 타입을 반환
  [K in DataKey as `get${Capitalize<K>}`]: () => DataEntry<K>;
};

// 다음과 같음

type DataEntryGetters = {
  getLocation: () => DataEntry<'location'>;
  getName: () => DataEntry<'name'>;
  getYear: () => DataEntry<'year'>;
};

keyof typeof 키워드를 사용해서 객체의 타입에서 매핑된 타입을 만드는 것도 가능하다

const config = {
  location: 'unknown',
  name: 'anonymous',
  year: 0,
};

type LazyValues = {
  [K in keyof typeof config as `${K}Lazy`]: () => Promise<(typeof config)[K]>;
};

// 다음과 같음

// type LazyValues = {
//     locationLazy: () => Promise<string>;
//     nameLazy: () => Promise<string>;
//     yearLazy: () => Promise<number>;
// }

async function withLazyValues(configGetter: LazyValues) {
  await configGetter.locationLazy();

  // 존재하지 않는 속성
  await configGetter.missingLazy();
}

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

type TurnIntogettersDirect<T> = {
  // K 의 타입은 Symbol
  // 원시타입에 Symbol 타입이 할당가능하지 않다.
  [K in keyof T as `get${K}`]: () => T[K];
};

이러한 제한 사항을 피하기 위해 string 과 교차타입 ( & ) 을 사용하여 문자열이 될 수 있는 타입만 사용하도록 강제한다.