3. lib.es5.d.ts 분석하기

김동현·2025년 9월 8일

typescript

목록 보기
9/9

1. Partial, Required, Readonly, Pick, Record - 일반 유틸리티 타입

타입스크립트에서 제공하는 유틸리티 타입이다.

Partial

기존 객체의 속성을 전부 옵셔널로 만드는 타입이다.

type MyPartial<T> = {
  [P in keyof T]?: T[P];
};
type Result = MyPartial<{ a: string; b: number }>;
// type Result = {
//     a?: string | undefined;
//     b?: number | undefined;
// }

Required

기존 객체의 속성을 전부 옵셔널이 아니게 만드는 타입이다.

type MyRequired<T> = {
  [P in keyof T]-?: T[P];
};
type Result = MyRequired<{ a?: string; b?: number }>;
// type Result = {
//     a: string;
//     b: number;
// }

Readonly

같은 원리로 Readonly도 가능하다.

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P];
};
type Result = MyReadonly<{ a: string; b: number }>;
// type Result = {
//     readonly a: string;
//     readonly b: number;
// }

Pick

기존 객체에서 지정한 속성만 추려내는 타입이다.

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
type Result = MyPick<{ a: string; b: number; c: number }, "a" | "c">;
// type Result = {
//     a: string;
//     c: number;
// }

참고로 T와 K는 특정 타입이 아니라 제네릭 타입인데 keyof T가 되는 이유는 keyof 연산자의 성질때문이다.

대상타입이 객체타입일 경우 속성들의 유니언을 반환하고 기본타입일 경우 기본타입의 래퍼 객체의 속성들의 유니언을 반환한다.

그래서 결국 "유니언 타입" 이 되버리는 것이고, "유니언 타입" 을 extends 한 K는 유니언 타입의 부분집합니다.

유니언 타입의 부분집합인 K는 in 연산자의 대상이 될 수 있다.

만약 K 타입 매개변수에 T의 속성이 아닌 값을 전달해도 에러가 아니라 무시하도록 만들고 싶으면 어떻게 할까

type Result = MyPick<{ a: string; b: number; c: number }, "d">;// 에러

"d" 가 T의 속성이 아니라서 에러가 뜬다.

컨디셔널 타입으로 해결 된다.

type MyPick<T, K> = {
  [P in K extends keyof T ? K : never]: T[P];
};
type Result = MyPick<{ a: string; b: number; c: number }, "a" | "d">;
// type Result = {
//     a: string;
// }

속성이 아닌 키를 never로 만들면 된다.

단, MyPick<{a:string}, d> 는 기존 객체에서 d만 끄집어내는 코드인데, 이럴 경우는 {} 가 반환된다.

알다시피 {}는 타입스크립트에서는 빈객체가 아니고 null과 undefined가 아닌 타입을 뜻하므로 의미가 달라지니 주의하자.

Record

지정한 속성을 지정한 타입이 되도록 객체 타입을 만드는 타입이다.

type MyRecord<K extends keyof any, T> = {
  [P in K]: T;
};
type Result = MyRecord<"a" | "b", string>;
// type Result = {
//     a: string;
//     b: string;
// }

2. Exclude, Extract, Omit, NonNullable - extends를 이용한 유틸리티 타입

Exclude

어떠한 타입에서 지정한 타입을 제거하는 타입

type MyExclude<T, U> = T extends U ? never : T;
type Result = MyExclude<1 | "2" | 3, "2">;
// type Result = 1 | 3

Extract

어떠한 타입에서 지정한 타입만 추출해내는 타입

type MyExtract<T, U> = T extends U ? T : never;
type Result = MyExtract<1 | "2" | 3, string>;
// type Result = "2"

Omit

특정 객체 타입에서 지정한 속성을 제거하는 타입

type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type Result = MyOmit<{ a: "1"; b: 2; c: true }, "a" | "c">;
// type Result = {
//     b: 2;
// }

NonNullable

타입에서 null과 undefined를 제거하는 타입

type MyNonNullable<T> = T extends null | undefined ? never : T;
type Result = MyNonNullable<string | number | null | undefined>;
// type Result = string | number

참고로 NonNullable타입 대신 ! (not null assertion)을 사용하면 되지않나 싶을텐데, 이 연산자는 값 뒤에 붙는 연산자이다.

타입 별칭 레벨에서 사용할 수 없다.

type Result2 = string | number | null | undefined;
type Result3 = Result2!; // 에러

하지만 위의 MyNonNullable 타입의 코드는 예전방식이고 요즘엔 다음과 같이 간편하게 사용한다.

type MyNonNullable<T> = T & {};

이를 통해 일부 속성만 옵셔널로 만드는 타입을 작성할 수 있다.

type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type Result = Optional<{ a: "hi"; b: 123 }, "a">;
// type Result1 = {
//     b: 123;
//     a?: "hi" | undefined;
// }

3. Parameters, ConstructorParameters, ReturnType, InstanceType - infer를 이용한 유틸리티 타입

type MyParameters<T extends (...args: any) => any> = T extends (
  ...args: infer P
) => any
  ? P
  : never;

type MyConstructorParameters<T extends abstract new (...args: any) => any> =
  T extends abstract new (...args: infer P) => any ? P : never;

type MyReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : any;

type MyInstanceType<T extends abstract new (...args: any) => any> =
  T extends abstract new (...args: any) => infer R ? R : any;

4. ThisType

"이 객체 리터럴 안의 메서드에서 this가 어떤 타입으로 추론되길 원한다" 라고 컴파일러에게 힌트를 주는 타입이다.

하지만 중요한 점은 ThisType<T> 자체는 런타임 동작을 바꾸지 않는다는 것이다.

다음과 같은 객체가 있다.

const obj = {
  data: {
    money: 0,
  },
  methods: {
    addMoney(amount: number) {
      this.money += amount; // 에러
    },
    useMoney(amount: number) {
      this.money -= amount; // 에러
    },
  },
};

this.data.money로 접근해야 하는데 this.money로 접근해서 에러가 난다.

하지만 만약 addMoney함수내의 this가 외부의 어떤 조작에 의해 obj가 아닌 {money, addMoney, useMoney}라고 가정한다면 정상동작 할 것이다.

그럴 경우 this타입도 같이 수정해야 한다.

이 예제에 타입을 추가해보자.

type Data = { money: number };
type Methods = {
  addMoney(this: Data & Methods, amount: number): void;
  useMoney(this: Data & Methods, amount: number): void;
};
type Obj = {
  data: Data;
  methods: Methods;
};

const obj: Obj = {
  data: {
    money: 0,
  },
  methods: {
    addMoney(amount: number) {
      this.money += amount;
    },
    useMoney(amount: number) {
      this.money -= amount;
    },
  },
};

타입 에러를 해결했지만, 앞으로 추가할 모든 메서드에 this를 일일이 타이핑 해야한다.

이럴 때 ThisType을 사용하면 중복을 제거할 수 있다.

type Data = { money: number };
type Methods = {
  addMoney(amount: number): void;
  useMoney(amount: number): void;
};
type Obj = {
  data: Data;
  methods: Methods & ThisType<Data & Methods>;
};

const obj: Obj = {
  data: {
    money: 0,
  },
  methods: {
    addMoney(amount: number) {
      this.money += amount;
    },
    useMoney(amount: number) {
      this.money -= amount;
    },
  },
};

참고로 ThisType은 타입스크립트 코드로는 구현할 수 없기에 비어있는 인터페이스만 선언해 다른 곳에서 사용할 수 있게 한 것이다.

이와 같은 타입은 내부 구현이 특별하게 처리되어 있을 가능성이 높다.

타입스크립트 레퍼런스를 살펴보면 다음과 같이 나와있다.

/**
 * Convert string literal type to uppercase
 */
type Uppercase<S extends string> = intrinsic;

/**
 * Convert string literal type to lowercase
 */
type Lowercase<S extends string> = intrinsic;

/**
 * Convert first character of string literal type to uppercase
 */
type Capitalize<S extends string> = intrinsic;

/**
 * Convert first character of string literal type to lowercase
 */
type Uncapitalize<S extends string> = intrinsic;

/**
 * Marker for non-inference type position
 */
type NoInfer<T> = intrinsic;

/**
 * Marker for contextual 'this' type
 */
interface ThisType<T> {}

intrinsic 역시 내부적으로 따로 구현이 되어있다는 뜻이다.

5. forEach 만들기

forEach는 이미 구현되어있으므로 myForEach를 타이핑해보자.

[1, 2, 3].myForEach(() => {}); // 에러

당연히 그냥 사용하면 에러가 난다.

배열의 메서드를 어떻게 타이핑할까?

이미 빌트인 되어있는 Array<T> 타입에 메서드를 추가하면 된다.

interface Array<T>{
  myForEach(): void;
}
[1, 2, 3].myForEach(() => {});

하지만 myForEach() 메서드의 콜백함수로 이것저것 넣어보면 에러가 난다.

[1, 2, 3].myForEach((v) => 3); // 에러
[1, 2, 3].myForEach((v, i) => console.log(v)); // 에러
[1, 2, 3].myForEach((v, i, a) => console.log(v, i, a)); // 에러

인수가 동작할 수 있도록 타이핑해보자.

interface Array<T> {
  myForEach(
    callback: (v: number, i: number, a: number[]) => void
  ): void;
}

콜백함수의 인수에 대한 에러가 사라졌다.

이번엔 배열를 변경해보자.

['a','b','c'].myForEach((v) => v.slice(0)); // 에러

배열의 요소들의 타입에도 정상동작하도록 제네릭으로 다시 타이핑한다.

interface Array<T> {
  myForEach(
    callback: (v: T, i: number, a: T[]) => void
  ): void;
}

타입스크립트의 forEach타입을 보면 this를 전달받는 매개변수가 있다.

이 부분도 추가해보자.

this는 어떻게 전달할까? 헷갈리면 앞에서 함수 및 메서드에 this를 전달하는 방법에 대해 설명한 부분을 다시 찾아 보자.

함수나 메서드의 첫 매개변수에 this를 전달하고, 호출 할때도 call()로 this를 전달하며 호출해야한다.

forEach()메서드의 콜백함수 호출부분은 forEach내부에서 이루어지므로 개발자가 따로 설정할 수없으므로 후출부분은 신경쓰지 않는다.

interface Array<T> {
  myForEach<K = typeof globalThis>(
    callback: (this: K, v: T, i: number, a: T[]) => void,
    thisArg?: K,
  ): void;
}

["a", "b", "c"].myForEach(
  () => {
    console.log(this); // 에러
  },
  { a: "b" },
);

["a", "b", "c"].myForEach(
  function () {
    console.log(this); // ok
  },
  { a: "b" },
);

참고로 this는 화살표함수에는 자체 this가 매핑되지 않으므로 this를 전달할 수도, 받을 수도 없다.

그러니 this를 전달할 때는 받는 함수가 화살표 함수가 아니어야 한다.

메서드도 마찬가지다.

또한, 위의 코드는 어디까지나 타이핑만 한 코드이므로 myForEach()의 실제 구현부는 없다.

실행하면 런타임 에러가 나온다.

즉, 타입스크립트에서 타입에러가 없더라도 실제로 실행됨이 보장되는 것은 아니라는 점을 알아두자.

6. map 만들기

const r1 = [1, 2, 3].myMap(() => {});
const r2 = [1, 2, 3].myMap((v, i, a) => v);
const r3 = ["1", "2", "3"].myMap((v) => parseInt(v));
const r4 = [{ num: 1 }, { num: 2 }, { num: 3 }].myMap(function (v) {
  return v.num;
});

interface Array<T> {
  myMap(callback: (v: T, i: number, a: T[]) => void): void; // 수정
}

myMap의 반환값이 현재 void로 되어있다.

map의 반환값은 매번 바꾸고 형식이 따로 없는데 어떻게 정의해야 할까?

이럴 때 쓰는 게 "제네릭 타입"

interface Array<T> {
  myMap<K>(callback: (v: T, i: number, a: T[]) => K): K[];
}

콜백 함수의 반환값으로 제네릭 K를 추론해내고 K의 배열 타입을 반환하도록 정의한다.

NOTE: 100% 정확하게 타이핑하는 것은 매우 어려운 일이다. 적당히 쓸 만하게 타이핑하는 것이 중요하다.

map에도 this인수가 있으므로 해당 작업을 추가하면 아래와 같이 된다.

interface Array<T> {
  myMap<K, U = typeof globalThis>(
    callback: (this: U, v: T, i: number, a: T[]) => K,
    thisArg?: U,
  ): K[];
}

그런데 사실 타입스크립트 공식 map의 타입정의를 보면 this를 이렇게 엄격하게 검사하지 않는다.

// 타입스크립트 공식
map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];

왜 이렇게 설계했을까?

그 이류로 첫 번째, 콜백 내부에서 this를 쓰는 패턴이 거의 사라졌기 때문이다.

JS 초창기(ES3/ES5)에는 thisArg를 쓰는 경우가 많았지만,

지금은 대부분 화살표 함수를 쓰기 때문에 this 자체를 쓰지 않는다.

따라서 타입 설계자 입장에서는 굳이 this 타입까지 정교하게 잡을 필요가 적음.

두 번째, 호환성 문제때문이다.

콜백에서 this를 타입으로 잡아버리면, 기존 코드와 충돌할 수 있다.

예를 들어 DOM API와 섞일 때도 this 타입 충돌 위험이 생긴다.

그래서 TS 팀은 그냥 "최소한 any로 뚫어두자" 쪽을 선택한 것이다.

7. filter 만들기

이제 바로 만들 수 있을 것 같다.

interface Array<T> {
  myFilter(callback: (v: T, i: number, a: T[]) => void): T[];
}

callback함수의 반환값이 boolean이 아닌 void인 이유는 자바스크립트에서 콜백함수의 반환값이 boolean이 아니더라도 동작하기 때문이다.

그런데 다음의 상황을 보자.

const r1 = [1, "2", 3].myFilter((v) => typeof v === "string"); 
// r1: (string | number)[]

콜백내부에서 타입 네로잉이 일어났다.

string 타입만 꺼냈으면 string[] 이 되어야 하는데 타입스크립트는 모른다.

제네릭 타입과 타입 서술 함수를 사용하면 된다.

interface Array<T> {
  myFilter<S extends T>(callback: (v: T, i: number, a: T[]) => v is S): S[];
}
const r1 = [1, "2", 3].myFilter((v) => typeof v === "string");
const r2 = [1, 2, 3].myFilter((v) => v<2); // 에러

r1과 r2 둘 다 boolean을 반환하는 콜백함수를 가졌는데 r1은 통과고 r2는 타입에러가 발생한다.

이는 r1, r2의 콜백함수가 타입 서술 함수임을 따로 명시해야 하는데 하지 않아서 발생한 것이다.

r1도 boolean을 반환하지만, TypeScript는 typeof v === 'string'이라는 패턴을 인식해서 이를 타입 서술 함수처럼 추론한다.

그래서 r2만 에러를 표시한다.

특정 패턴을 다 외울 수는 없으므로 안전하게 타입 서술 함수임을 명시하는 것이 좋다.

const r1 = [1, "2", 3].myFilter((v): v is string => typeof v === "string");
const r2 = [1, 2, 3].myFilter((v): v is number => v<2);

헷갈리는 부분이 있을 수 있다.

기존의 콜백함수는 타입을 정의해놓으면 사용할 때 별도로 타이핑을 하지 않아도 되었다.

심지어 매개변수의 개수조차 달라도 타입에러를 일으키지 않았다.

하지만 타입 서술 함수임은 명시해야하므로 주의하자.

그런데 사용할때마다 타입 서술 함수임을 명시하면 얼마나 귀찮을까?

타입 내로잉을 하지 않는 함수를 사용할 때가 대부분일 텐데?

그래서 함수 오버로딩을 한다.

interface Array<T> {
  myFilter<S extends T>(
    callback: (v: T, i: number, a: T[]) => v is S,
    thisArg?: any,
  ): S[];
  myFilter(callback: (v: T, i: number, a: T[]) => unknown, thisArg?: any): T[];
}

반환타입이 boolean도 아니고 void도 아니고 unknown인 이유는 무엇일까?

콜백이 반환하는 값은 boolean일 수도 있고, number, string, object, null, undefined, 등 어떤 타입이든 가능하다.

따라서 반환 타입을 boolean으로 제한하면 실제 사용 가능한 다양한 truthy/falsy 값을 표현하지 못한다.

void는 반환값이 없음을 의미하므로, filter의 목적과 맞지 않는다.

unknown은 모든 타입을 포함할 수 있는 가장 넓은 타입이면서, 타입 안전성을 유지할 수 있다.

8. reduce 만들기

const r1 = [1, 2, 3].myReduce((a, c) => a + c);
const r2 = [1, 2, 3].myReduce((a, c, i, arr) => a + c, 10);
const r3 = [{ num: 1 }, { num: 2 }, { num: 3 }].myReduce(function (a, c) {
  return { ...a, [c.num]: "hi" };
}, {});
const r4 = [{ num: 1 }, { num: 2 }, { num: 3 }].myReduce(function (a, c) {
  return a + c.num;
}, "");

테스트 케이스를 준비해둔다.

이제 myReduce를 타이핑해보자.

interface Array<T> {
  myReduce(callback: (a: T, c: T, i: number, arr: T[]) => T, iV?: T): T;
}

초기값이 있을 수도 없을 수도 있으니 옵셔널로 타이핑했다.

콜백함수의 반환 타입이 요소의 타입과 같지 않을 수도 있다.

위의 타이핑은 r3, r4를 만족시키지 못한다.

생각해보면 반환 타입이 요소의 타입과 달라진다는 것은 초기값이 요소타입과 다를 경우이다.

그래서 해당 경우도 오버로딩한다.

interface Array<T> {
  myReduce(callback: (a: T, c: T, i: number, arr: T[]) => T, iV?: T): T;
  myReduce<S>(callback: (a: S, c: T, i: number, arr: T[]) => S, iV?: S): S;
}

물론 초기값과 상관없이 return true 처럼 다른 타입의 상수를 반환하거나 억지로 다른 타입을 반환하도록 작성할 수도 있고 이렇게 작성해도 자바스크립트에서는 돌아가지만 이런 경우는 타입스크립트에서도 타입 에러로 본다.

타입스크립트에서 작성한 reduce를 보자.

reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;

reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;

reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;

뭐 대충 맞다.

9. flat 분석하기

배열의 flat은 다음과 같은 기능을 제공한다.

const A = [[1, 2, 3], [4, [5]], 6];

const R = A.flat(); // [1, 2, 3, 4, [5], 6], R: (number | number[])[]
const RR = R.flat(); // [1, 2, 3, 4, 5, 6], RR: number[]
const RRR = RR.flat(); // [1, 2, 3, 4, 5, 6], RRR: number[]

const R2 = A.flat(2); // [1, 2, 3, 4, 5, 6], R2: number[]

flat은 타이핑이 상당히 어려우므로 타입스크립트의 타이핑을 분석만 해보자.

flat은 ES2019에 추가된 기능이므로, 타입스크립트도 그에 맞춰야 한다.

interface Array<T> {
    flatMap<U, This = undefined>(
        callback: (this: This, value: T, index: number, array: T[]) => U | ReadonlyArray<U>,
        thisArg?: This,
    ): U[];
    flat<A, D extends number = 1>(
        this: A,
        depth?: D,
    ): FlatArray<A, D>[];
}

lib.es2019.array.d.ts 파일에 정의되어 있다.

보니까 es2019에 flat말고도 flatMap도 추가된 것을 볼 수 있다.

flat만 보자.

interface Array<T> {
    flat<A, D extends number = 1>(
        this: A,
        depth?: D,
    ): FlatArray<A, D>[];
}

A타입이 this 매개변수의 타입이므로, A는 원본 배열로 추론할 것이다.

D는 flat메서드의 매개변수인 낮출 차원 수를 의미한다.

근데 D extends number = 1 이라는 문법을 처음 보면 헷갈릴 것이다.

number = 1을 extends 한다?

놉. number와 = 1 을 따로 봐야한다.

타입 제약과 기본값 설정이 같이 적용되어서 이런 형태가 된 것이다.

즉, D에 인수를 전달하면 D extends number 가 되고

D를 생략하면 D = 1이 되는 것이다.

위에서 작성한 예시코드를 보면 flat()으로 매개변수를 생략한 코드가 있다.

이 경우 D의 값이 1이 된다.

depth = 1 로 매개변수의 기본값으로 설정을 하면 되지 않나? 왜 타입 레벨에서 1로 설정했지? 라는 의문이 든다면 명심하자.

타입스크립트는 타이핑만 하는거고, 매개변수의 기본값을 설정하는건 자바스크립트에서 이미 구현이 되었다는 것을.

인수가 생략되었을 때 depth가 1로 동작하는건 이미 자바스크립트 단계에서 구현이 되었다.

그러니 depth가 생략될 수도 => 옵셔널

생략되었을 때 depth가 1 => depth 생략시 depth타입인 D가 1

로 타이핑이 제대로 되었다.

여튼 FlatArray<A, D>[]를 호출하니 해당 타입의 정의부분으로 가보자.

type FlatArray<Arr, Depth extends number> = {
    done: Arr;
    recur: Arr extends ReadonlyArray<infer InnerArr> ? FlatArray<InnerArr, [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20][Depth]>
        : Arr;
}[Depth extends -1 ? "done" : "recur"];

먼저 FlatArray는 크게 보았을 때, 인덱스 접근 타입으로 볼 수 있다.

{...}["done"]{...}["recur"] 로 나누어진다.

Depth가 -1이면 {...}["done"]가 되고 이 타입은 Arr타입이다.

Depth가 -1이 아닐경우, 위의 복잡한 코드가 실행된다.

Depth가 -1이 아니라 가정하고 따로 분리해보자.

type FlatArray<Arr, Depth extends number> = Arr extends ReadonlyArray<
  infer InnerArr
>
  ? FlatArray<
      InnerArr,
      [
        -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
        11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
      ][Depth]
    >
  : Arr;

Arr이 3차원 배열 (number | (number | number[])[])[] 라고 가정해보자.

그래서 FlatArray<(number | (number | number[])[])[], 1> 에서 시작해야 한다.

먼저 Arr extends ReadonlyArray<infer InnerArr> 이 참인지를 확인해야 한다.

ReadonlyArray는 readonly가 적용된 배열 타입이다.

요즘은 ReadonlyArray 말고 그냥 readonly T[]로 쓴다.

readonly를 배열에 붙이면 타입이 넓어진다고 했다.

일반 배열을 readonly 배열에 대입할 수 있다.

하지만 ReadonlyArray는 readonly T[]와는 다르다.

대입될지 안될지는 모르니까 테스트로 해보면 된다.

let src:Array<any> = [];
let arr:ReadonlyArray<any> = src; // ok
src = arr; // 에러

일반 배열이 ReadonlyArray에 대입되므로 ReadonlyArray이 더 넓은 타입임을 확인했다.

따라서 배열 extends ReadonlyArray<infer InnerArr>는 참이므로 infer를 통해 배열의 요소 타입인 InnerArr를 유추한다.

InnerArr은 이름을 통해 []가 한꺼풀 벗겨진 배열이란 것을 유추할 수 있지만, 어떻게 추론되는지는 다음과 같이 확인해보자.

type GetInner<Arr> = Arr extends ReadonlyArray<infer InnerArr>
  ? InnerArr
  : never;
type OneDepthInner = GetInner<(number | (number | number[])[])[]>;
// type OneDepthInner = number | (number | number[])[];

ReadonlyArray의 타입인수를 통해 대괄호가 벗겨지는 것을 확인할 수 있다.

자 그다음, FlatArray<InnerArr, [-1,0,...,20][Depth]> 에서 두 번째 인수가 뜻하는 것이 뭘까?

두 번째 타입 인수를 보면 또 인덱스 접근 타입이다.

Depth가 0이면 -1, 1이면 0, 이렇게 한 단계씩 낮춘다.

타입스크립트는 숫자 리터럴 타입의 +, -연산이 불가능하다.

따라서 고육지책으로 저렇게 인덱스 접근 타입을 이용해 Depth을 1씩 낮춘것이다.

그럼 Depth가 22면 어떨까? 뭘 묻나. 추론못한다.

자 정리해보면 다음과 같다.

FlatArray<(number|(number|number[])[])[], 1>
  = FlatArray<number|(number|number[])[], 0>
FlatArray<number|(number|number[])[], 0>
  = number|FlatArray<number|number[], -1>
  = number|number|number[]
  = number|number[]

중간에 number와 FlatArray의 유니언이 튀어나왔다.

왜일까? 잘 생각해보자.

첫 번째 단계에서의 타입인수는 배열 타입이다. 그래서 ReadonlyArray<infer T> 에 따라 T가 배열의 요소타입으로 추론되고 그대로 반환된다.

그러나 두 번째 단계에서의 타입인수는 유니언 타입이다.

유니언 타입과 제네릭 타입이 만나면? 분배법칙.

그리고 마지막 Depth 가 -1일 때는 앞서 살펴봤듯이 "done"키를 갖는 인덱스 접근 타입 정의에 따라 타입인수 그대로 반환한다.

10. Promise, Awaited 타입 분석하기

Promise와 Awaited 타입을 분석해보자.

new Promise

const str = new Promise((resolve, reject) => {
  resolve("abc");
}); // str: string

Promise는 ES2015에 도입된 기능이므로 lib.es2015.promise.d.ts에 들어있다.

Promise의 타입 정의를 보자.

declare var Promise: PromiseConstructor;

Promise는 PromiseConstructor 타입이다.

PromiseConstructor를 보자.

interface PromiseConstructor {
    readonly prototype: Promise<any>;
    new <T>(executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;
    all<T extends readonly unknown[] | []>(values: T): Promise<{ -readonly [P in keyof T]: Awaited<T[P]>; }>;
    race<T extends readonly unknown[] | []>(values: T): Promise<Awaited<T[number]>>;
    reject<T = never>(reason?: any): Promise<T>;
    resolve(): Promise<void>;
    resolve<T>(value: T): Promise<Awaited<T>>;
    resolve<T>(value: T | PromiseLike<T>): Promise<Awaited<T>>;
}

이 중에 생성자 함수 타입을 보자.

new <T>(executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;

복잡해 보이는데 매개변수가 executor이고 반환 타입이 Promise<T> 인 생성자 함수 타입이다.

executor인 매개변수는 resolvereject를 매개변수로 받고 반환 타입을 특정하지 않는 콜백함수이다.

resolve 매개변수는 value를 전달 받고 반환값을 특정하지 않는 콜백함수이고 reject 매개변수는 reason를 전달 받고 반환값을 특정하지 않는 콜백함수이다.

그리고 resolve 콜백함수의 인자로 들어오는 타입을 기준으로 생성자 함수의 반환 타입인 Promise<T> 가 결정된다.

예를 들어보자.

const a = new Promise((resolve)=>{resolve(1)}); 
// a: Promise<unknown>
const b = new Promise<number>((resolve)=>{resolve(1)}); 
// b: Promise<number>

첫 번째 호출에서 unknown인 이유가 뭘까?

resolve(1) 로 호출했고 매개변수에 1이 들어갔으니 T가 1로 추론되어서 Promise<1> 이 되야할텐데 Promise<unknown>으로 추론되었다.

그 이유는 제네릭 타입 선언 <T> 가 생성자 함수 레벨에 붙어 있기 때문이다.

생성자 함수 호출할때 T를 추론하는 것이지, 그 함수 내부에서 호출할 때는 T를 추론하지 못한다.

function example1<T>(callback: (result: (param:T)=>T) => void) {}
// 첫 번째 호출 방식
example1((result) => {
  const r = result(1); // r: unknown
});

function example2(callback: (result: <T>(param:T)=>T) => void) {}
// 두 번째 호출 방식
example2((result) => {
  const r = result(1); // r: 1
});

따라서 new Promise를 호출할때 제네릭으로 타입을 넘겨주거나, value의 타입을 명시적으로 표기하면 된다.

const c = new Promise((resolve:(value:number)=>void)=>{resolve(1)}); 
// c: Promise<number>

resolve의 매개변수인 value는 T타입 말고도 PromiseLike<T> 타입을 전달받을 수 있다.

이름에서 유추할 수 있듯이 promise와 비슷한 객체일 것 같은데 한 번 타입을 보자.

interface PromiseLike<T> {
    then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): PromiseLike<TResult1 | TResult2>;
}

then메서드를 가지고 있는 객체이다.

then 메서드는 onfulfilled 매개변수를 갖고 PromiseLike 즉 자신의 타입을 반환한다.

이런 객체를 thenable 객체라고 부른다.

const thenable:PromiseLike<number> = {
  then(onfulfilled, onrejected) {
    if (onfulfilled) onfulfilled(42);

    return {
      then: () => {
        // dummy 체이닝
        return this;
      }
    };
  }
}
const c = new Promise((resolve)=>{resolve(thenable)}); 
// c: Promise<unknown>

Promise.resolve

const a = Promise.resolve("abc"); // a: Promise<string>
const b = await Promise.resolve("abc"); // b: string

여기서는 제네릭을 명시하지 않았는데도 Promise<string>타입을 추론한다.

왜 그런지 분석해보자.

resolve(): Promise<void>;
resolve<T>(value: T): Promise<Awaited<T>>;
resolve<T>(value: T | PromiseLike<T>): Promise<Awaited<T>>;

resolve 메서드는 오버로딩되어 있다.

보면 알겠지만 제네릭이 resolve함수 레벨에 붙어있다.

그래서 resolve호출시에 T가 추론되므로 resolve("abc") 에서 T가 매개변수 "abc"에 의해 string으로 추론된다.

매개변수 "abc"는 thenable 객체가 아니므로 두 번째 오버로딩된 함수타입이 적용될 것이고 반환타입은 Promise<Awaited<T>> 이다.

이 타입의 실행결과가 Promise<string> 인데, 왜 이렇게 되는지 Awaited타입을 분석해보자.

type Awaited<T> = T extends null | undefined
  ? T
  : T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
  ? F extends (value: infer V, ...args: infer _) => any
    ? Awaited<V>
    : never
  : T;

복잡해보여서 그런지 타입스크립트에서 주석을 주렁주렁 달아놔서 제거하여 가지고 왔다.

조건식이 3개 중첩되어 있다. 하나씩 보자.

우리는 Awaited<string>string 이 되는 과정을 분석할 것이다.

첫 번째 조건식에서 T가 null 또는 undefined라면 T를 반환한다.

  • T=null : null
  • T=undefined : undefined

두 번째 조건식에서 T가 원시타입이 아니면서 thenable한 객체라면 넘어가고 아니라면 바로 T를 반환한다.

  • T=string : string
  • T=number : number
  • T=boolean : boolean
    ..

Awaited<string> 이 string이 된 것은 여기서 걸러졌기 때문이다.

따라서 반환타입이 Promise<string> 이 된다.

Promise<string> 타입이니까 당연히 체이닝으로 쭉쭉 진행해도 된다.

이번엔 b타입을 분석해보자.

Promise.resolve() 앞에 await이 붙으면 기존 타입에 Awaited 타입이 감싸게 되어 Awaited<Promise<Awaited<string>>> 이 된다.

이게 string 타입이 되는 과정을 보자.

위에서 보인 두 번째 조건식의 결과에 따라 Awaited<Promise<string>>이 되고 여기서 부터 분석한다.

이번에는 T가 Promise<string> 타입이다.

Promise<string> 타입이 thenable한지 확인해보자.

interface Promise<T> {
  then<TResult1 = T, TResult2 = never>(
    onfulfilled?:
      | ((value: T) => TResult1 | PromiseLike<TResult1>)
      | undefined
      | null,
    onrejected?:
      | ((reason: any) => TResult2 | PromiseLike<TResult2>)
      | undefined
      | null,
  ): Promise<TResult1 | TResult2>;
  catch<TResult = never>(
    onrejected?:
      | ((reason: any) => TResult | PromiseLike<TResult>)
      | undefined
      | null,
  ): Promise<T | TResult>;
}

thenable하므로 두 번째 조건식에서 참이 된다.

onfulfilled 콜백함수의 타입을 추론하고 해당 타입의 매개변수(value) 타입을 추론한다.

Promise<string> 타입은 value의 타입도 string이므로 infer V에서 V는 string이 된다.

따라서 세 번의 조건식을 통해 Awaited<T>Awaited<string> 이 된다.

한 번 더 재귀 수행을 하면 Awaited<string> 가 string이 된다.

그래서 결국 Awaited<Promise<Awaited<string>>> 가 string이 된다.

Promise.all

마지막으로 all()메서드 타입을 분석해보자.

all<T extends readonly unknown[] | []>(
    values: T,
  ): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }>;

value 매개변수를 받고 Promise<...>를 반환하는 함수이다.

타입이 복잡하므로 호출 예시를 통해 알아보자.

const all = await Promise.all([
    "abc",
    Promise.resolve(123),
    Promise.resolve(Promise.resolve(true)),
  ]); // all: [string, number, boolean]

먼저 value는 T타입이고 T타입은 배열이 아니라 정확히는 튜플이다.

아! value타입이 배열인지 튜플인지 중요하므로 짚고 넘어가도록 하자.

T는 unknown[]로 제약이 되었다. 만약 T extends readonly unknown[] 이었다면 T는 배열로만 추론된다.

하지만 | []가 뒤에 붙어있다.

이는 문법적으로 “배열 또는 빈 튜플”이 맞지만 추론시에는 다르게 해석된다.

타입 추론 규칙상, 이 패턴은 “배열뿐 아니라 튜플 타입도 받아줄 수 있다”는 시그널이 된다.

그래서 T extends unknown[] | []로 열어 두면, 컴파일러는 리터럴 배열을 만났을 때 튜플로 추론한다.

반환타입인 Promise의 타입인수는 매핑된 객체 타입의 정의에 따라서 모든 속성에 readonly를 제거하고 값에는 Awaited가 감싸진다.

시각화하면 아래와 같이 나타난다.

{
  "0": Awaited<'abc'의 타입>,
  "1": Awaited<Promise.resolve(123)의 타입>,
  "2": Awaited<Promise,resolve(Promise.resolve(true))의 타입>,
  "length": Awaited<3>,
  ...
}

만약 튜플이 아니라 배열이었다면 문자열 인덱스 키가 없어서 위와 같이 나열되지 못한다.

위에서 Awaited를 분석했었다.

먼저 thenable이 아니면 타입 그대로 반환되므로 키가 "1" , "2" 인 값들을 제외하고 모든 속성은 원래 자신의 값을 갖는다.

{
  "0": 'abc',
  "1": Awaited<Promise.resolve(123)의 타입>,
  "2": Awaited<Promise.resolve(Promise.resolve(true))의 타입>,
  "length": 3,
  ...
  // 나머지 속성은 기존 값 유지
}

Awaited<Promise<T>>T 와 대응되었다.

Promise.resolve(123)은 Promise<Awaited<123>> => Promise<123> 이다.

그래서 Awaited<Promise<123>> 이 되고 결국 123 이 타입이 된다.

{
  "0": 'abc',
  "1": 123,
  "2": Awaited<Promise.resolve(Promise.resolve(true))의 타입>,
  "length": 3,
  ...
  // 나머지 속성은 기존 값 유지
}

Promise.resolve(Promise.resolve(true)) 의 매개변수 타입은 Promise<Awaited<true>> => Promise<true> 가 되므로 바깥쪽 resolve는 함수 오버로딩중에 세 번째에 해당된다.

세 번째 오버로딩도 반환타입은 Promise<Awaited<T>>로 같다.

그런데 매개변수 타입을 보면 헷갈린다.

Promise<true>PromiseLike<true> 에 호환이 될 뿐더러

동시에 T = Promise<true>로도 해석이 가능하다.

유니언 타입중 어디를 선택하느냐에 따라 T의 추론타입이 달라진다.

TypeScript는 다음과 같은 기준으로 T를 추론한다.

  1. 전달된 값이 union 타입의 어느 분기와 더 구체적으로 일치하는가

  2. 가능한 한 T를 단순한 타입으로 추론하려고 시도

Promise<true>PromiseLike<T> 에 대응할 때 T가 더 단순해지므로 T가 true가 된다.

그래서 Promise<Awaited<true>> => Promise<true> 가 된다.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글