-
03-타입스크립트고급기능Programming/Typescript 2021. 11. 22. 18:43반응형
인프런 이재승 님 타입스크립트 시작하기 강의를 듣고 여러 레퍼런스를 참고하여 정리한 노트입니다.
1. 제네릭(generic)
제네릭은 타입 정보가 동적으로 결정되는 타입이다.
제네릭을 통해 같은 규칙을 여러 타입에 적용할 수 있다. 타입을 지정할 때 발생할 수 있는 중복 코드를 제거할 때 유용하다.
제네릭이 없을 때 문제점
function makeNumberArray(defaultValue: number, size: number): number[] { const arr: number[] = []; for (let i = 0; i < size; i++) { arr.push(defaultValue); } return arr; } function makeStringArray(defaultValue: string, size: number): string[] { const arr: string[] = []; for (let i = 0; i < size; i++) { arr.push(defaultValue); } return arr; } const arr1 = makeNumberArray(1, 10); const arr2 = makeStringArray('empty', 10);위의 코드에서 두 개의 함수는 로직은 거의 비슷하지만, 함수의 반환값과 들어가는 요소의 값이 다르다.
function makeArray(defaultValue: number, size: number): number[]; function makeArray(defaultValue: string, size: number): string[]; function makeArray( defaultValue: number | string, size: number | string ): Array<number | string> { const arr: Array<number | string> = []; for (let i = 0; i < size; i++) { arr.push(defaultValue); } return arr; } const arr1 = makeArray(1, 10); const arr2 = makeArray('empty', 10);위 코드처럼 함수 오버로드를 이용하면 코드의 중복은 사라지고 정상 작동한다.
하지만 위 코드는 number와 string만 사용할 수 있다.
boolean타입을 사용하고 싶다면
function makeArray(defaultValue: boolean, size: number): boolean[]; function makeArray(defaultValue: number, size: number): number[]; function makeArray(defaultValue: string, size: number): string[]; function makeArray( defaultValue: number | string | boolean, size: number ): Array<number | string | boolean> { const arr: Array<number | string | boolean> = []; for (let i = 0; i < size; i++) { arr.push(defaultValue); } return arr; } const arr1 = makeArray(1, 10); const arr2 = makeArray('empty', 10);위와 같이 코드를 수정해줘야 한다. 만약 사용해야 할 타입이 계속해서 늘어난다면 오버로드로 처리하는 데는 한계가 있다.
제네릭을 이용한 해결
제네릭을 사용해서 위의 문제점을 해결한다.
// 제네릭: T는 원하는 이름으로 지정할 수 있다. function makeArray<T> ( defaultValue: T, size: number): T[] { const arr: T[] = []; for (let i = 0; i < size; i++) { arr.push(defaultValue); } return arr; } // 제네릭에 타입 전달 const arr1 = makeArray(1, 10); const arr2 = makeArray('empty', 10); const arr3 = makeArray(true, 10); const arr4 = makeArray<number>(2, 10); const arr5 = makeArray<string | number>('empty', 10);제네릭을 이용하고자 하는 함수를 선언할 때 함수 이름 옆에
<>를 이용하여 동적 타입을 지정해준다.이 동적 타입은 파라미터, 구현부 모두에서 사용할 수 있다.
해당 동적 타입은 함수를 선언할 때는 정해지지 않으며 함수를 호출할 때 동적으로 지정되게 된다.
위 코드에서처럼 따로
<>을 사용하지 않아도 암묵적으로 지정이 된다.하지만 명시적으로 표현해주고 싶을 땐
<>를 이용한다.자료구조에서의 제네릭 이용
제네릭은 데이터 타입에 다양성을 부여하기 때문에 자료구조에서 많이 사용된다.
class Stack<D> { private items: D[] = []; push(item: D) { this.items.push(item); console.log(this) } pop() { return this.items.pop(); } } const numberStack = new Stack<number>(); numberStack.push(10); const v1 = numberStack.pop(); // v1는 number | undefined const stringStack = new Stack<string>(); stringStack.push('a'); const v2 = stringStack.pop(); // v2는 string | undefined let myStack: Stack<number> myStack = numberStack; myStack = stringStack; // error. number만 가능하다.위와 같이 클래스에 사용할 때도 동일하게 이름 오른쪽에
<>를 사용한다.제네릭 타입의 범위 제한
지금까지는 제네릭에 아무 타입이나 집어넣을 수 있었다.
하지만 React와 같은 라이브러리의 API는 입력 가능한 값의 범위를 제한한다.
예를 들어 리액트의 속성값 전체는 객체 타입만 허용된다.
extends라는 키워드를 이용하면 제네릭으로 들어올 수 있는 타입을 제한할 수 있다.function identity<T extends number | string>(p1: T): T { return p1; } identity(1); identity('a'); identity(true); // error. boolean은 number나 string이 아니다. identity([]); // error. 배열은 number나 string이 아니다.A extends B라고 했을때, A가 B에 할당 가능해야 한다. 라고 읽으면 된다.즉, T는 number | string에 할당 가능해야 한다.
keyof와 예시1
keyof는 타입값에 존재하는 모든 프로퍼티의 키를 union형태로 리턴한다.interface Person { name: string; age: number; } interface Korean extends Person { liveInSeoul: boolean; } // p1, p2라는 개체를 입력 받고, key를 입력받은 다음 해당 key의 value를 서로 바꿔주는 함수 function swapProperty<T extends Person, K extends keyof Person> ( p1: T, p2: T, key: K ): void { const temp = p1[key]; p1[key] = p2[key]; p2[key] = temp; } const p1: Korean = { name: '류시명', age: 30, liveInSeoul: true } const p2: Korean = { name: '김노엘', age: 54, liveInSeoul: false } swapProperty(p1, p2, 'name'); swapProperty(p1, p2, 'age'); swapProperty(p1, p2, 'liveInSeoul'); // error.K는
keyof에 의해'name | 'age'가 된다.name과age는 인자로서 사용할 수 있지만liveInSeoul은 K에 포함되지 않기 때문에 사용할 수 없다.예시2
interface Person { name: string; age: number; } interface Korean extends Person { liveInSeoul: boolean; } function swapProperty<T extends Person, K extends keyof Person> ( p1: T, p2: T, key: K ): void { const temp = p1[key]; p1[key] = p2[key]; p2[key] = temp; } interface Product { name: string; price: number; } const p1: Product = { name: '시계', price: 1000 } const p2: Product = { name: '자전거', price: 2000 } swapProperty(p1, p2, 'name') // error.p1, p2는 Product타입이고, Product 타입은 Person에 할당할 수 없다. 따라서 error가 발생한다.
2. 맵드 타입(mapped type)
맵드 타입 mapped type을 이용해서 모든 속성을 optional로 바꾸거나 readonly로 바꾸는 등의 일을 할 수 있다.
interface Person { name: string; age: number; } interface PersonOptional { name?: string; age?: number; } interface PersonReadOnly { readonly name: string; readonly age: number; }// mapped type 문법 type T1 = { [K in 'prop1' | 'prop2']: boolean }; // 두 코드는 같다. type T1 = { prop1: boolean; prop2: boolean; }- 맵드 타입으로 만들어지는 건 객체이기 때문에 중괄호를 사용한다.
- []는 key부분을 뜻한다.
- K는 아무 이름으로 작성해도 상관 없고 in 오른쪽이 중요하다.
- 현재 두 개의 문자열 리터럴을 유니온 타입으로 지정한 상황이다.
선택 속성 지정
interface Person { name: string; age: number; } // T의 모든 key값을 선택속성이고 boolean 타입으로 지정한다. type MakeBoolean<T> = { [P in keyof T]?: boolean }; const pMap: MakeBoolean<Person> = {}; pMap.name = true; pMap.age = false; pMap.age = undefined; // 선택 속성이기 때문에 사용 가능 pMap.age = 1; // error.T라는 값의 key의 union값을 이용하여 key를 만들고 그걸 선택속성으로 지정된다.
따라서 pMap의 name과 age에는 boolean과 undefined는 입력 가능하지면 number는 불가능하다.
enum에 응용
맵드 타입을 이용하면 enum 타입의 활용도를 높일 수 있다.
enum Fruit { Apple, Banana, Orange } const FRUIT_PRICE = { [Fruit.Apple]: 1000, [Fruit.Banana]: 1500, [Fruit.Orange]: 2000 }위와 같은 코드가 있을 때, enum 타입에 변화가 발생하면 아래 FRUIT_PRICE에서 변화가 있어야 한다.
enum Fruit { Apple, Banana, Orange, Orange2, } const FRUIT_PRICE = { [Fruit.Apple]: 1000, [Fruit.Banana]: 1500, [Fruit.Orange]: 2000, [Fruit.Orange2]: 2500, }위와 같이 코드를 바꿔줘야 하는데 이는 실수가 생길 여지가 많다.
enum Fruit { Apple, Banana, Orange, Orange2, } // error const FRUIT_PRICE: { [key in Fruit]: number } = { [Fruit.Apple]: 1000, [Fruit.Banana]: 1500, }이렇게 in 오른쪽에 enum 타입을 작성하면 enum내 모든 아이템을 나열해야만 한다.
Orange와 Orange2를 모두 정의해줘야 error가 해제된다.
// good enum Fruit { Apple, Banana, Orange, Orange2, } const FRUIT_PRICE: { [key in Fruit]: number } = { [Fruit.Apple]: 1000, [Fruit.Banana]: 1500, [Fruit.Orange]: 2000, [Fruit.Orange2]: 2500, }맵드타입을 이용한 내장 타입: Readonly, Partial
두 내장 타입의 구조는 아래와 같다.
// readonly로 바꾸고 원래 속성의 타입을 그대로 유지하겠다. type Readonly<T> = { readonly [P in keyof T]: T[P] }; // optional로 바꾸고 원래 속성의 타입을 그대로 유지하겠다. type Partial<T> = { [P in keyof T]?: T[P] };위 코드에서
T[P]라는 코드가 있는데 이는인터페이스[key]문법으로 이는 해당 key의 타입값이 리턴된다. 즉type T1 = Person['name']; // string위와 같다.
내장 타입 Readonly, Partial을 사용하게 되면
interface Person { name: string; age: number; } type T2 = Readonly<Person>; type T3 = Partial<Person>;결과로
type T2 = { readonly name: string; readonly age: number; } type T3 = { name?: string; age?: number; }가 된다.
맵드타입을 이용한 내장 타입: Pick
Pick의 구조는 아래와 같다
type Pick<T, K extends keyof T> = { [P in K]: T[P] };Pick은 두 개의 제네릭을 받는데,
하나는 T라는 인터페이스이고, 하나는 T의 key의 union값의 부분집합인 K를 받는다.
그 둘을 받아서 처리하면 name과 language만 뽑아서 그대로 아래처럼 객체 타입을 만들어 준다.
interface Person { name: string; age: number; language: string; } type T1 = Pick<Person, 'name' | 'language'>;위 코드는 아래를 결과로 낸다.
type T1 = { name: string; age: string; }맵드타입을 이용한 내장 타입: Record
Record의 구조는 아래와 같다.
type Record<K extends string, T> = { [P in K]: T };interface Person { name: string; age: number; language: string; } type T1 = Record<'p1' | 'p2', Person> type T2 = Record<'p1' | 'p2', number>Record라는 내장 타입은 문자열로 이루어진 제네릭과 타입을 받는다.
그리고 아래와 같이 주어진 문자열을 key, 주어진 타입을 value로 하는 객체 타입 구조를 반환한다.
type T1 = { p1: Person, p2: Person, } type T2 = { p1: number, p2: number, }3. 조건부 타입
조건부 타입은 입력된 제네릭 타입에 따라 타입을 결정할 수 있는 기능.
// 조건부타입 T extends U ? X : Y // T가 U에 할당 가능하면 X라는 타입. 아니면 Y라는 타입. type IsStringType<T> = T extends string ? 'yes' : 'no'; type T1 = IsStringType<string>; type T2 = IsStringType<number>;T1은 'yes' 타입이고, T2은 'no' 타입이 된다.
자바스크립트의 삼항연산자와 비슷하지만 값이 아니고 타입을 다룬다는 것에 주의한다.
조건부 타입과 유니온(|)
조건부 타입은 유니온과 함께 자주 사용되는데 그 작동이 좀 특이하다.
type IsStringType<T> = T extends string ? 'yes' : 'no'; type T1 = IsStringType<string | number>; type T2 = IsStringType<number> | IsStringType<string>;위 코드에서 string | number는 string보다 더 크기 때문에 할당이 되지 않아 T1은 'no'라는 리터럴을 타입으로 가질 것 같지만 실제로는
type T1 = 'yes' | 'no'; type T2 = 'yes' | 'no';위처럼 작동되고, T2도 동일하다. 이는 조건부 타입을 사용할 때만 적용되는 특이한 사항으로,
T에 string이 들어올 경우를 적용, number가 들어올 경우를 적용한 뒤 유니온으로 묶어주는 순서의 결과이다.
type Array2<T> = Array<T>; type T3 = Array2<string | number>조건부 타입을 사용하지 않으면 위와 같은 결과가 나오지 않고 아래와 같은 결과가 나온다.
type T3 = (string | number)[];조건부 타입을 이용한 내장 타입: Exclude와 Extract
먼저 그 전에 union에서 never는 제거된다는 사실을 먼저 짚고 넘어가자.
type T1 = number | string | never; // 위는 아래와 같다. type T1 = number | string;Exclude와 Extract의 구조는 아래와 같다.
type Exclude<T, U> = T extends U ? never : T; type Extract<T, U> = T extends U ? T : never;type T2 = Exclude<1 | 3 | 5 | 7, 1 | 5 | 9>; type T3 = Exclude<string | number | (() => void), Function>; type T4 = Extract<1 | 3 | 5 | 7, 1 | 5 | 9>;Exclude는 T와 U를 입력받아서 T가 U에 할당 가능하면 never, 아니면 T를 그대로 사용한다.
조건부 타입에서 유니온이 사용되면 일단 먼저 각각 조건부 타입에 적용되므로,
1부터 순서대로 1, 3, 5, 7이 U(1 | 5 | 9)에 할당 가능한지 검사한다.
1과 5는 할당이 가능하니 never가 되고, 3과 7을 불가능하니 그대로 각각 3과 7이 되는데 이를 union으로 묶어주면 never가 사라지므로
type T2 = 3 | 7;이 된다.
위와 같은 원리로 T3는 string과 number가 U(Function)에 할당할 수 없으므로
type T3 = string | number;가 된다.
Extract는 Exclude의 반대이다.
따라서
type T4 = 1 | 5;가 된다.
조건부 타입을 이용한 내장 타입: ReturnType
ReturnType의 구조는 아래와 같다.
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;ReturnType은 T가 함수일 때 T의 반환 타입을 반환하며, T가 함수가 아니면 any를 반환한다.
type T1 = ReturnType<() => string>; // T1은 string function f1(s: string): number { return s.length; } // 제네릭에는 타입만 입력할 수 있기 때문에 f1의 type을 넣기 위해 typeof 키워드 사용 type T2 = ReturnType<typeof f1>; // T2는 numberT1과 T2의 타입은 아래와 같다.
type T1 = string; type T2 = number;infer 더 알아보기
infer 키워드는 조건부 타입을 정의 할 때 extends 뒤에 사용되며
R이라는 변수에 함수의 반환 타입을 담기 위해 사용되며 아직 결정되지 않은 값을 사용해야 할 때 사용한다.
type Unpacked<T> = T extends (infer U)[]// 1. T가 어떤 값의 배열이면 ? U // 그 배열의 아이템의 타입을 사용한다. : T extends (...args: any[]) => infer U // 2. 배열이 아니고 함수에 할당가능한 값이라면 ? U // 함수의 반환 타입을 사용한다 : T extends Promise<infer U> // 3. 함수에 할당 가능하지 않고 Promise에 할당 가능한 타입이라면 ? U // Promise의 값인 U를 사용한다 : T; // 4. T 자기 자신을 사용한다. type T0 = Unpacked<string>; // 4. string type T1 = Unpacked<string[]>; // 1. string type T2 = Unpacked<() => string>; // 2. string type T3 = Unpacked<Promise<string>>; // 3. string type T4 = Unpacked<Promise<string>[]>;// 1. Promise<string> type T5 = Unpacked<Unpacked<Promise<string>[]>>; // 1. Promise<string> -> 3. string위 코드에서 infer는 Unpacked타입을 정의할 때 여러번 사용되고 있다.
조건부 타입을 작성하면서
- 배열일지도 모르는 것의 배열 요소의 타입
- 함수일지도 모르는 것의 반환 타입
- Promise일지도 모르는 것의 타입갑
을 추론해내고 있다.
조건부 타입 응용
조건부 타입을 사용해서 몇 가지 유틸리티 타입을 만들어 보자.
type StringPropertyNames<T> = { [K in keyof T]: T[K] extends string ? K : never; }[keyof T]; interface Person { name: string; age: number; nation: string; } type T1 = StringPropertyNames<Person>;위의 StringPropertyNames 타입이 어떻게 작동되는지 차근히 살펴보자.
type StringPropertyNames<T> = { [K in keyof T]: T[K] extends string ? K : never; }만 보면 맵드 타입으로서
type T1 = { name: 'name'; age: never; nation: 'nation'; }가 되고 인터페이스에 ['key']를 입력하면, 해당 key에 해당하는 타입값을 반환한다.
여기서 T는 Person이므로
keyof Person은'name' | 'age' | 'nation'이 된다.또 유니온에서 never는 제거가 되므로
interface Person2 { name: 'name'; age: never; nation: 'nation'; } type T2 = Person2['name']; // 'name' 타입 type T3 = Person2['name' | 'nation']; // 'name' | 'nation' 타입 type T4 = Person2['name' | 'nation' | 'age']; // 'name' | 'nation' 타입위와 같은 결과가 나온다.
따라서
type StringPropertyNames<T> = { [K in keyof T]: T[K] extends string ? K : never; }[keyof T]; interface Person { name: string; age: number; nation: string; } type T1 = StringPropertyNames<Person>;에서 T1은
type T1 = 'name' | 'nation'위와 같다.
type StringPropertyNames<T> = { [K in keyof T]: T[K] extends string ? K : never; }[keyof T]; interface Person { name: string; age: number; nation: string; } type T1 = StringPropertyNames<Person>; type StringProperties<T> = Pick<T, StringPropertyNames<T>>; type T2 = StringProperties<Person>;따라서 위 코드에서 T2는
type T2 = { name: string; nation: string; }위와 같다.
조건부 타입을 이용한 내장 타입: Omit
Omit의 구조는 아래와 같다.
type Omit<T, U extends keyof T> = Pick<T, Exclude<keyof T, U>>;T를 입력받고, U는 T의 key들의 유니온에 할당 가능해야 하며,
그 동작은 입력 받은 T의 key들의 유니온 값들 중에서 U를 제거한 뒤(Exclude) T에서 남은 key와 타입값을 골라 객체 타입으로 만든다.(Pick)
interface Person { name: string; age: number; nation: string; } // Person이라는 인터페이스에서 nation과 age를 제거한다. type T1 = Omit<Person, 'nation' | 'age'>;결과는
type T1 = { name: 'string'; };이다.
조건부 타입 응용2
// T에 U를 덮어 씌운다. type Overwrite<T, U> = { [P in Exclude<keyof T, keyof U>]: T[P] } & U; interface Person { name: string; age: number; } type T1 = Overwrite<Person, { age: string, nation: string }>; const p: T1 = { name: 'simi', age: '23', nation: 'kor' }위 Overwrite 타입은 T와 U라는 두 인터페이스를 받아서 T에 U를 덮어씌우는 유틸 타입이다.
그 구현 과정을 순서로 나타내면
- Exclude -> T와 U에서 겹치는 속성이 있으면 T에서 해당 속성 제거한 뒤 객체 타입 재생성(맵드타입)
- 재생성된 객체 타입과 U를 교집합
이다. 즉, T1은
- Person과 { age: string, nation: string }의 겹치는 속성인 age를 제거
- { name: string } 과 { age: string, nation: string } 교집합
하여
type T1 = { name: string } & { age: string, nation: string }위와 같이 결과가 나타난다.
반응형'Programming > Typescript' 카테고리의 다른 글
04-생산성을높이는타입스크립트의기능 (0) 2021.11.22 02-타입호환성 (0) 2021.11.22 01-타입정의하기 (0) 2021.11.22 00. 타입스크립트 시작하기 (0) 2021.11.22