티스토리 뷰
인프런 이재승 님 타입스크립트 시작하기 강의를 듣고 여러 레퍼런스를 참고하여 정리한 노트입니다.
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는 number
T1과 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 |