iamkanguk.dev

[Type Challenge] Omit 문제에 대한 고찰 (readonly 관련) 본문

Type Challenge

[Type Challenge] Omit 문제에 대한 고찰 (readonly 관련)

iamkanguk 2024. 9. 27. 17:19

https://github.com/type-challenges/type-challenges/blob/main/questions/00003-medium-omit/README.md

 

type-challenges/questions/00003-medium-omit/README.md at main · type-challenges/type-challenges

Collection of TypeScript type challenges with online judge - type-challenges/type-challenges

github.com

 

TypeScript에서 지원하는 Utility Type인 Omit을 직접 구현해보는 문제였다.

해당 문제를 구현하면서 헷갈렸던 부분을 정리하려고 한다. Omit에 대한 자세한 설명을 하지는 않겠다.

(Omit<T, K> -> T에서 K들을 빼고 리턴하는 것>

풀이1)

type cases = [
  Expect<Equal<Expected3, ThirdMyOmit<Todo1, 'description' | 'completed'>>>,
]

interface Todo1 {
  readonly title: string
  description: string
  completed: boolean
}

interface Expected3 {
  readonly title: string
}

// 메인 풀이
type FirstMyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

 

- T의 Key들을 순회할 때 P라는 변수를 가지고 순회를 할건데 P가 K에 할당이 가능하다면 never를 통해 Index Signature에서 제외시키고, 할당이 불가능하다면 적용한다. 그리고 해당 경우에는 T를 P로 접근하여 value를 가져온다.

- 가장 직관적이고 무난한 풀이라고 생각이 된다.

 

풀이2) Exclude를 사용한 풀이 (오답)

type cases = [
  Expect<Equal<Expected3, ThirdMyOmit<Todo1, 'description' | 'completed'>>>,
]

interface Todo1 {
  readonly title: string
  description: string
  completed: boolean
}

interface Expected3 {
  readonly title: string
}

// 메인 풀이
type SecondMyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};

 

- Omit은 T에서 K를 제외하고 반환하는 타입이기 때문에 Exclude를 사용할 수 있다는 의미이다.

- Exclude를 사용해서 T에서 K를 제외한 타입을 가져오고, 그것을 P라는 변수를 통해 순회해서 객체를 만들어준다는 의미이다.

- 하지만 해당 코드는 TypeScript Playground에서 에러를 발생시키고 있다. 이유는 무엇일까? 이후에 확인해보자.

에러가 발생하는 이유?

확인해보니 readonly를 인식하지 못해서 에러가 발생하는 것 같다.

그래서 왜 케이스를 통과를 못하는지 생각을 해보다가, keyof를 적용할 때 readonly 부분을 잡아주지 못해서 그랬다고 생각했다.

하지만 기본적으로 객체 안에서 keyof를 사용하면 readonly는 무시되지 않는 다는 것이다. 이게 무슨말인가?

interface Todo {
   readonly title: string;
   description: string;
}

type keyOfTodo = keyof Todo;   // "title" | "description"

type Hello = {
	[P in keyof Todo]: T[P]
};   // { readonly title: string; description: string; }

 

- Todo라는 타입에 keyof Todo를 하면 readonly가 제거된 상태로 결과가 출력된다.

- 하지만, Hello라는 타입을 보면 객체 안에서 keyof Todo를 하면 readonly가 유지된 채로 결과가 출력된다.

- 이 부분을 인지하지 못해서 헷갈렸던 것 같다.

 

그러면 다시 위의 코드가 에러가 발생하는 이유에 대해서 생각해보자.

Exclude는 결과 타입이 객체가 아닌 유니온 타입이다.

type Fruit = "cherry" | "banana" | "strawberry" | "lemon";

type RedFruit = Exclude<Fruit, "banana" | "lemon">;
// type RedFruit = "cherry" | "strawberry" 와 같다.

 

방금 위에서 객체 타입에서 사용한 keyof는 readonly가 유지된다고 언급했는데, Exclude의 결과 타입이 유니온 타입이기 때문에 readonly가 유지가 되지 않는다. 그래서 P in Exclude<keyof T, K> 를 하게 되면 readonly가 빠진 채로 순회가 되기 때문에 에러가 발생하고 있는 것이다.

 

풀이3) Exclude를 활용하면서 해결하는 방법

 

일단 왜 에러가 발생하는지는 파악이 되었다. 그러면 Exclude를 통해서 해결하는 방법이 있을까?

type cases = [
  Expect<Equal<Expected3, ThirdMyOmit<Todo1, 'description' | 'completed'>>>,
]

interface Todo1 {
  readonly title: string
  description: string
  completed: boolean
}

interface Expected3 {
  readonly title: string
}

// 메인 풀이
type ThirdMyOmit<T, K extends keyof T> = {
  [P in keyof T as Exclude<P, K>]: T[P];
};

 

아까 봤던 코드랑 약간 다르다. 일단 as 라는 키워드를 "그런데" 라고 해석해보자.

인지했으면 코드를 천천히 해석해보자.

 

먼저, Exclude<P, K>는 P extends K ? never : T; 라고 치환할 수 있을 것 같다.

치환한 뒤의 코드를 살펴보자.

type cases = [
  Expect<Equal<Expected3, ThirdMyOmit<Todo1, 'description' | 'completed'>>>,
]

interface Todo1 {
  readonly title: string
  description: string
  completed: boolean
}

interface Expected3 {
  readonly title: string
}

// 메인 풀이
type ThirdMyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : T]: T[P];
};

 

이렇게 보면 뭔가 조금씩 보인다.

 

1. keyof를 객체 내에서 사용했기 때문에 readonly 상태가 유지된다.

2. 유지되는 상태에서 P를 추출했기 때문에 P에는 readonly가 적용된 Property Key가 올 수 있다.

3. 그 P가 K에 할당될 수 있으면 빼고, 할당이 되지 않는다면 Index Signature 상태로 유지한다.

 

그래서 우리는 Exclude 유틸리티 타입을 사용해서 문제를 해결할 수 있었다.


 

최대한 다른 분들이 보기에 이해하기 쉽게 작성해봤는데 이해가 되실지는 모르겠다.

그래도 필자와 같이 해당 문제를 풀 때 이런 고민을 한적이 있다면 도움이 되었으면 좋겠다.

 

핵심은 객체 내에서의 keyof는 readonly를 유지할 수 있다는 것.

 

NestJS 오픈채팅 톡방에서 도움을 주신 Michael님 감사합니다.

 

- https://chanhuiseok.github.io/posts/ts-3/

- https://bkdragon0228.tistory.com/m/3