티스토리 뷰

인프런 이재승 님 타입스크립트 시작하기 강의를 듣고 여러 레퍼런스를 참고하여 정리한 노트입니다.

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' 가 된다.

nameage는 인자로서 사용할 수 있지만

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;
}
  1. 맵드 타입으로 만들어지는 건 객체이기 때문에 중괄호를 사용한다.
  2. []는 key부분을 뜻한다.
  3. K는 아무 이름으로 작성해도 상관 없고 in 오른쪽이 중요하다.
  4. 현재 두 개의 문자열 리터럴을 유니온 타입으로 지정한 상황이다.

선택 속성 지정

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타입을 정의할 때 여러번 사용되고 있다.

조건부 타입을 작성하면서

  1. 배열일지도 모르는 것의 배열 요소의 타입
  2. 함수일지도 모르는 것의 반환 타입
  3. 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를 덮어씌우는 유틸 타입이다.

그 구현 과정을 순서로 나타내면

  1. Exclude -> T와 U에서 겹치는 속성이 있으면 T에서 해당 속성 제거한 뒤 객체 타입 재생성(맵드타입)
  2. 재생성된 객체 타입과 U를 교집합

이다. 즉, T1은

  1. Person과 { age: string, nation: string }의 겹치는 속성인 age를 제거
  2. { 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
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
글 보관함