flatten 오브젝트 타입 추론

깊이가 1 이상인 object 타입들을 다룰 때, 우리는 간혹 flatten 이라고 불리우는 기능을 요구한다.
flatten 이란 깊이가 1 이상인 object들을 일정한 키 생성 규칙에 따라 깊이가 1로 고정된 오브젝트로 전환하는 기능을 말한다.

말로 하면 복잡한데 직접 보면 간단하다.

만약 아래와 같은 값이 있다고 생각해 보자.

const a = {
  a : "key 'a'",
  b : {
    aa : "key 'b.aa'",
    bb : "key 'b.bb'",
  },
  c : {
    aa : {
      aaa : "key 'c.aa.aaa'",
      bbb : "key 'c.aa.bbb'",
    }
  },
}

이 타입을 flatten 하면 아래와 같은 값이 되어야 한다.

const a = {
  "a" : "key 'a'",
  "b.aa" : "key 'b.aa'",
  "b.bb" : "key 'b.bb'",
  "c.aa.aaa" : "key 'c.aa.aaa'"
  "c.aa.bbb" : "key 'c.aa.bbb'"
}

이러한 flatten의 장점은 사용에 따라 클 수도 있기에 간혹 최적화에서 사용하는 테크닉이다.

만약 이러한 기능을 구현하는 함수를 만든다면 이러한 형태일 것이다.

function flatten(target : object) : object{
  ...
}

그런데 갑자기 이런 생각이 들었다.

타입스크립트의 제네릭 추론은 누가 마이크로소프트제 아니랄까봐 매우 강력한데 혹시 이러한 형태의 추론이 가능할까?

function flatten<T>(target : T) : TypeFlatten<T>{
  ...
}

위의 코드에서 T를 이용해 T가 flatten된 타입 형태인 TypeFlatten<T>를 추론할 수 있다면 object보다 훨씬 유용한 개발자 경험을 만들 수 있을 것이다.

그래서 시도해 봤고, 일단 먼저 결과부터 보여드리자면 매우 잘 된다.

여기서부터는 어떻게 만드는지, 어떤 장단점이 있는지에 대한 글이다. 만약 최종 코드만 빨리 보고 싶으면 최종 구현 부분으로 가면 된다.

위의 사례처럼 result의 리턴 타입들로 나올 키들을 추론하는 모습을 볼 수 있다.

다만 이 코드에는 단점도 존재한다, 단점은 아래에 나열된 바와 같다.

  • 코드 자체가 난해하다. 아마 타입스크립트와는 별개로 제네릭에 대해 잘 모르면 이해하기 힘들 수 있다.
  • const, 혹은 정적인 object들만 위와 같은 추론이 가능하다.
    이는 당연한 결과인데 위와 같은 추론을 위해 나는 제네릭을 이용하였고, 제네릭은 당연히 컴파일 타임에 추론 가능한 것들로 구현한다.
    따라서 위와 같은 추론은 아무 타입에나 가능한 것은 아니다.
  • object 추론에 깊이 제한이 있다.
    이는 타입스크립트가 제네릭 분석을 재귀적으로 하는 것에 제한을 두기 때문이다.
    만약 무한 깊이 재귀를 구현한다면 ts2589 에러코드와 함께 컴파일을 거부한다.
    따라서 무제한 깊이는 불가능하고 프로그래머가 직접 하드코딩과 유사하게 깊이를 직접 구현하는 수 밖에 없다.

그 외에도 주의할 점이 있다면,

아직까지는 경험해 보지 못했기는 한데, 이렇게 제네릭을 복잡하게 구성하면 매우 복잡하고 큰 오브젝트에서 자동완성을 도와주는 기능이 박살나거나, 매우 느려질 수도 있다.
다만 이런 현상은 경험해보지는 못했고, 아마 오브젝트가 아주아주 커야 생길까 말까한 문제이기는 한데, 일단 가능성은 있다고 여겨진다.
다만 typescript 언어 서버가 매우 잘 만들어져 있다면 이런 문제는 생기지 않을지도 모른다. 솔직히 확신을 못해서 일단 가능성만 이야기하고 넘어간다.

그러면 이제부터 어떻게 이 기능을 구현했는지 단계별로 설명해 보도록 하겠다.

미리 얘기하지만 솔직히 난 이걸 잘 설명할 자신이 없다. 그러니 코드를 짤 때 고안한 아이디어 위주로 설명할거다.
낮선 코드일 뿐 하나하나 놓고보면 별로 어려운 내용은 아니라 코드를 보면 이해할 수 있을 것이다.

타입스크립트와 제네릭만의 특수한 문법

type

우선 타입스크립트에서는 타입 별칭을 지정하기 위한 type 키워드가 있다.

C 에 익숙한 사람들에게는 typedef와 사용처가 비슷하다고 할 수 있다.

하지만 타입스크립트에는 C와 다르게 제네릭이 존재하고 type은 제네릭을 지원해 C와는 차원이 다른 사용성을 자랑하게 된다.

또, 다른 언어에선 true 타입이나 false타입같은 것은 말이 안되지만 타입스크립트는 오로지 true값, 혹은 flase값 같은 특정 값만 받아들이는 타입을 정의할 수 있다.

이 두가지가 조합되 type 문은 타입스크립트에서 일종의 제네릭용 함수처럼 이용할 수 있다.

이를 사용하는 방법은 다음과 같다.

type A = number
type B<T> = { K: T };
type C = B<A>

여기서 C는 아래와 같은 타입이 된다.

keyof

keyof 문은 해당 타입에서 정적으로 알 수 있는 오브젝트의 키들을 컴파일 시점에서 알아낼 때 사용하는 문법이다.

이는 사용법이 매우 간단하다 아래 문법을 보면 쉽게 알 수 있을 것이다.

type A = { K: string; N: number; B: boolean };
type B = keyof A;
function fb(b : B){...}

이렇게 만들면 fb의 매개변수는 K, N, B중 하나만 사용 가능하게 된다.

type or, type and

or 타입이나, and 타입은 매우 간단하다.
특정 타입이 둘 중 하나를 만족하거나 둘 다 만족하게 만드는 제네릭 기능이다.

type A = number | string
type B = { A : number } & { B : string}
type C = { A : number, B : string}

이 결과는 A는 number, 혹은 string 타입 둘중 하나를 허용하는 타입이 되고
B는 C와 본질적으로 같은 타입이 된다.

보통 많이 사용되는 or 타입하고는 다르게 and 타입은 보통 이렇게 오브젝트를 병합할 때만 주로 이용된다.
생각해 보면 다양한데 type A = number & string 이라고 생각하면 number 이면서 동시에 string인 타입이라는 의미인데 이런 타입은 존재할 수 없다.

extends ?

type IsNumber<T> = T extends number ? true : false;
type TypeResult0 = IsNumber<0>
type TypeResult1 = IsNumber<"">
type TypeResult2 = IsNumber<{}>
<> extends <대상> 
	? <만약 값이 대상과 호환되는 타입이라면> 
   : <만약 값이 대상과 호환되지 않는 타입이라면> 

위 구문은 타입스크립트에서 제네릭을 추론할 때만 사용가능한 문법으로 제네릭 계의 If문이나 삼항연산자 ? 정도로 생각하면 된다.

이 코드는 T 타입이 number 형식이면 true 타입으로 추론하는 제네릭이다.

실제 vscode에서 값을 보면 각각은 다음과 같다.

보다시피, 제네릭 T로 숫자형을 넘긴 TypeResult0 만 true로 유추되는 것을 볼 수 있다.

이제 타입을 일종의 제네릭 버전 함수처럼 이용 가능함을 알았다.

infer

infer키워드는 다른 언어에서는 보기 힘든, 타입스크립트만의 독특한 문법으로, 타입을 역추론하는데 이용된다.

말로 하면 힘든데 직접 보면 쉽다. 아래 코드를 보자.

type A<T> = T extends `${infer S}-${infer B}` ? [S, B] : T;
type C = A<"1-2">;

직접 해보기 전까지는 이런 문법이 있을거라 생각도 못했다...

flat타입 추론

위의 기능들을 모두 알아야 flatten 타입을 만들어 볼 수 있다.

우선 대략적인 방법은 아래와 같다.

  1. object 타입들의 키들을 보고 해당 키의 값이 number | string | boolean | undefined | null | bigint중 하나인지 확인한다.
  2. 만약 해당 값이 위의 것중 하나라면 키를 리턴한다.
  3. 만약 위의 값중 하나가 아니라면 키를 .으로 붙이고 재귀적으로 다시 1번부터 진행한다.
  4. 이 과정이 끝나면 목표로 하는 object의 키들을 모두 얻는다. 이제 각 키에 해당하는 타입을 분석한다.
  5. 키로부터 infer를 통해 .단위로 분해하며 오브젝트를 재귀적으로 타고 들어간다.
  6. 마지막으로 해당하는 타입을 찾는다.
  7. 이제 flat 키와 flat 타입을 합쳐 flatten 오브젝트로 만든다.

그러면 위의 순서에 맞게 들어가 보자.

키-값 타입 확인

첫번째로 특정 타입인지 확인하기 위한 기능이 필요하다.
이는 extends를 통해 구현 가능하지만 확인해야 하는 타입이 너무 많으므로 별칭을 만들어 놓으면 편할 것이다. 따라서 아래 타입을 정의한다.

type Primitivs = number | string | boolean | undefined | null | bigint

재귀적인 flatkey 추론

object를 재귀적으로 분석해 키를 flatten된 형태의 키를 추출해야 한다.

이는 다음과 같다.

type FlatKeys<T, K extends keyof T> = 
  T[K] extends Primitives 
    ? `${K}`
    : `${K}.${FlatKeys<T[K], keyof T[K]>}`

그런데 이는 컴파일이 안된다. 이유는 K 가 symbol 타입인 경우 ${K}처럼 리터럴에 넣는게 불가능하기 때문인데 이를 해소하기 위해선 K가 symbol이 아닌, string 타입임을 지정해 주어야 한다.

type FlatKeys<T, K extends keyof T> = K extends string
  ? T[K] extends Primitives
    ? `${K}`
    : `${K}.${FlatKeys<T[K], keyof T[K]>}`
  : never;

never는 제네릭에서 이 타입은 무시하라는 의미이다. return null 정도의 의미라고 생각하면 된다.

중간과정 점검을 해 보면 다음과 같다.

잘 된다.

flatten 오브젝트로 전환해주는 식

이제 여기까지 왔으면 flatten된 오브젝트를 얻는 것은 매우 쉽다.

아래 제네릭은 특정 키들을 모두 가지는 오브젝트를 생성하는 제네릭이다.

사실 이 문법은 말로 설명할 자신이 없어서 그냥 보여만 주겠다.

type Flatten<T> = {
  [K in FlatKeys<T, keyof T>] : any
}

이렇게 추론하면 결과는 아래와 같다.

이제 훌륭하게 flatten 제네릭을 구현했지만 한가지 문제가 있다.

여기서 보면 A의 B.AA 필드는 원래 number 타입인데 그냥 any 타입으로 추론된다.

이를 해결하기는 쉽지 않은데 이를 추론해주는 기능을 아래에서 만들 것이다.

값 역추론

B.AA같은 형태의 flat키로부터 T에서 특정 값을 찾는 것은 매우 어렵다.

다만 불가능한 것은 아닌데 infer 기능으로 하나하나 파고 들어가면 되기 때문이다.

이 기능은 아래와 같다.

type FlatValue<T, K extends string> = 
  K extends `${infer PRE}.${infer POST}`
  ? PRE extends keyof T
    ? FlatValue<T[PRE], POST>
    : never
  : K extends keyof T
    ? T[K]
    : never;

이를 테스트해보면 아래와 같다.

다만 위의 방식은 한가지 단점이 있는데 T에 .이 들어간 키를 지원하지 않는다는 것이다.

이는 infer 구현방식 때문에 그런데 이를 해결하기 위해서는 FlatValue 자체가 좀 더 복잡해 질 수밖에 없다.

이를 해결하는 방법은 아래에서 계속하겠다.

값 역추론, 업그레이드

위의 값 역추론은 일반적인 경우에는 잘 동작하지만 .을 포함한 키가 중간에 존재한다면 문제가 생긴다. 이는 아래 코드의 주석이 달린 부분 때문이다.

type FlatValue<T, K extends string> = 
  K extends `${infer PRE}.${infer POST}`
  ? PRE extends keyof T
    ? FlatValue<T[PRE], POST>
    : // 여기가 문제
	  never
  : K extends keyof T
    ? T[K]
    : never;

이를 해결하는 것은 더 복잡해 지는데 전부다 설명하면 끝이 없으니 바로 코드부터 보여주겠다.

type WithBase<B extends string, K extends string> = B extends `` ? K : `${B}.${K}`

type FlatValue<T, K extends string, BASE extends string = ""> = 
  K extends `${infer PRE}.${infer POST}`
  ? WithBase<BASE, PRE> extends keyof T
    ? FlatValue<T[WithBase<BASE, PRE>], POST>
    : FlatValue<T, POST, WithBase<BASE, PRE>>
  : WithBase<BASE, K> extends keyof T
    ? T[WithBase<BASE, K>]
    : never;

간단히 설명하면 BASE 라고 하는 부분을 이용해서 구현하였다.
이전 재귀에서 못 찾았다면 다음 재귀에서는 PRE와 BASE를 합쳐서 분석하는 방법으로 해결했다.

설명이 부족함은 스스로도 느끼지만 도저히 이를 말로 설명을 못하겠다.

이를 사용하면 아래와 같은 결과가 나온다.

이제 .이 들어간 키도 잘 분석한다.

FlatKey와 FlatValue의 조합

최종적으로 Flatten 타입을 만들어 주기 위해서 표현식을 만들면 아래와 같다.


type Primitives = number | string | boolean | undefined | null | bigint;

// type FlatKeys<T extends Record<string | number, any>> = FlatKeys1<T, keyof T>
type FlatKeys<T, K extends keyof T> = K extends string
  ? T[K] extends Primitives
    ? `${K}`
    : `${K}.${FlatKeys<T[K], keyof T[K]>}`
  : never;

type WithBase<B extends string, K extends string> = B extends `` ? K : `${B}.${K}`

type FlatValue<T, K extends string, BASE extends string = ""> =
  K extends `${infer PRE}.${infer POST}`
  ? WithBase<BASE, PRE> extends keyof T
    ? FlatValue<T[WithBase<BASE, PRE>], POST>
    : FlatValue<T, POST, WithBase<BASE, PRE>>
  : WithBase<BASE, K> extends keyof T
    ? T[WithBase<BASE, K>]
    : never;

type Flatten<T> = {
  [K in FlatKeys<T, keyof T>]: FlatValue<T, K>;
};

이를 사용한 결과는 아래 사진과 같다.

최종 구현

최종적으로 모든 소스코드와 flatten 함수까지 만들면 코드는 아래와 같다.

type Primitives = number | string | boolean | undefined | null | bigint;

// type FlatKeys<T extends Record<string | number, any>> = FlatKeys1<T, keyof T>
type FlatKeys<T, K extends keyof T> = K extends string
  ? T[K] extends Primitives
    ? `${K}`
    : `${K}.${FlatKeys<T[K], keyof T[K]>}`
  : never;

type WithBase<B extends string, K extends string> = B extends ``
  ? K
  : `${B}.${K}`;
type FlatValue<
  T,
  K extends string,
  BASE extends string = ""
> = K extends `${infer PRE}.${infer POST}`
  ? WithBase<BASE, PRE> extends keyof T
    ? FlatValue<T[WithBase<BASE, PRE>], POST>
    : FlatValue<T, POST, WithBase<BASE, PRE>>
  : WithBase<BASE, K> extends keyof T
  ? T[WithBase<BASE, K>]
  : never;

type Flatten<T> = {
  [K in FlatKeys<T, keyof T>]: FlatValue<T, K>;
};

export function flatten<T extends Record<string, any>>(t: T): Flatten<T> {
  let result: Record<string, any> = {};
  function inner(t: Record<string, any>, base = "") {
    for (const [k, v] of Object.entries(t)) {
      const fk = base.length === 0 ? k : `${base}.${k}`;
      if (
        ["number", "string", "boolean", "undefined", "null", "bigint"].includes(
          typeof v
        )
      ) {
        result[fk] = v;
      } else {
        inner(v, fk);
      }
    }
  }
  inner(t)
  return result as Flatten<T>;
}

이 함수를 실제 사용하면 아래와 같은 결과가 나온다.

보다시피 type safe한 코드 작성에 도움이 된다.


타입스크립트의 제네릭과 그 추론은 정말 상상을 초월할 정도로 강력한 것 같다.

특히 infer는 전에 본적 없는 강력한 추론 기능이다.

타입스크립트는 type safe 한 코드 작성을 도와주는데 제네릭을 잘 이용한다면 아주아주 강력한 type safe 코드를 작성할 수 있을 것 같다.

앞으로도 타입스크립트로 가능한 제네릭 추론을 이용한 코드가 생각나면 종종 올리도록 하겠다.

좋은 웹페이지 즐겨찾기