원문: Fix Multiple Issues with Indexed Access Types Applied to Mapped Types

주석 블록은 글의 이해를 위해 역자가 추가로 작성한 내용입니다.


이 PR은 mapped type에 indexed access type을 적용하는 것과 관련한 몇가지 이슈에 대한 수정사항입니다. 이 수정사항들은 #30581에서 제기된 대부분의 문제들을 "distributive object type"을 이용한 접근방식으로 해결할 수 있게 합니다.

여기서 "distributive object type"는 mapped type에 indexed access type을 적용하여 만들어진 타입을 말합니다.

*용어 참고
1. Mapped Types
2. Indexed Access Types

먼저, 우리가 해결하려하는 문제는 다음과 같습니다:

type UnionRecord = 
    | { kind: "n", v: number, f: (v: number) => void }
    | { kind: "s", v: string, f: (v: string) => void }
    | { kind: "b", v: boolean, f: (v: boolean) => void };

function processRecord(rec: UnionRecord) {
    rec.f(rec.v);  // Error, 'string | number | boolean' not assignable to 'never'
}

UnionRecord 타입은 연관된 두 프로퍼티 vf를 가지는 레코드의 union입니다. v의 타입은 항상 f의 매개변수의 타입과 같습니다.

processRecord 함수에서는 레코드의 vrec.f(rec.v)로 호출하여 이 상관관계를 활용하고 있습니다. 그러나 이 코드는 type checker의 관점에서는 안전하지 않습니다. Type checker의 관점에서는 rec.v의 타입이 string | number | boolean이고, rec.f의 타입이 (v: never) => void입니다. vnever인 것은 string & number & boolean의 교집합으로 계산되기 때문입니다. Type checker는 "어떤" 레코드의 v를 "어떤" 레코드의 f에 전달할 수 있는지 확인하려 하기 때문에 vf가 같은 레코드에서 온다는 점은 고려하지 않습니다.

이제 이런 패턴의 타입을 작성할 때 사용할 수 있는 접근법을 소개하겠습니다. 이 접근법의 핵심은 union 타입property name이 될 수 있는 타입(e.g. string 리터럴 타입, 또는 unique symbol 타입)에 대해 "판별할 수 있는(discriminant)" 속성을 가지고 있다는 것입니다. 아래의 예시들은 이 PR의 수정사항이 있어야 성공적으로 컴파일 된다는 것을 알아두세요.

아래의 UnionRecord 타입은 종류(n, s, b)와 타입(number, string, boolean), 그리고 연관된 속성들(v, f)의 대응관계를 포함하도록 형성됩니다:

type RecordMap = { n: number, s: string, b: boolean };
type RecordType<K extends keyof RecordMap> = { kind: K, v: RecordMap[K], f: (v: RecordMap[K]) => void };
type UnionRecord = RecordType<'n'> | RecordType<'s'> | RecordType<'b'>;

RecordMap의 각 키 K에 대응하는RecordType<K>의 union을 하나하나 작성해주는 대신, 아래처럼 작성할 수 있습니다.

type UnionRecord = { [P in keyof RecordMap]: RecordType<P> }[keyof RecordMap];

indexed access type을 mapped type에 적용하는 패턴은, 타입을 (여기서 RecordType<P> union에 대해 (여기서 keyof RecordMap) 분배하는 방법입니다.

한 발 더 나아가, UnionRecord가 임의의 키들에 대해 만들어질 수 있도록 할 수 있습니다.

type UnionRecord<K extends keyof RecordMap = keyof RecordMap> = { [P in K]: RecordType<P> }[K];

그 후, RecordType<K>UnionRecord<K>에 합쳐서 분배되지 않은 레코드가 생길 가능성을 없애줍니다. 그럼 마지막 결과가 만들어집니다:

type RecordMap = { n: number, s: string, b: boolean };
type UnionRecord<K extends keyof RecordMap = keyof RecordMap> = { [P in K]: {
    kind: P,
    v: RecordMap[P],
    f: (v: RecordMap[P]) => void
}}[K];

이 방식을 통해 우리는 processRecord 함수의 타입을 작성할 수 있습니다. 이제 type checker가 종류, 타입, 그리고 프로퍼티 간의 관계를 추론할 수 있습니다. 또한 UnionRecord 개별로 사용하면 union으로 잘 동작하는 것을 알 수 있습니다.

function processRecord<K extends keyof RecordMap>(rec: UnionRecord<K>) {
    rec.f(rec.v);  // Ok
}

declare const r1: UnionRecord<'n'>;  // { kind: 'n', v: number, f: (v: number) => void }
declare const r2: UnionRecord;  // { kind: 'n', ... } | { kind: 's', ... } | { kind: 'b', ... }

processRecord(r1);
processRecord(r2);
processRecord({ kind: 'n', v: 42, f: v => v.toExponential() });

그리고 모든것이 RecordMap의 매핑으로 표현되었기 때문에 새로운 종류와 데이터 타입이 한 곳(RecordMap)에만 추가되면 되어서 DRY 원칙을 훌륭하게 만족합니다.

profile
해외 개발 관련 컨텐츠 번역. 자연스럽게 읽을 수 있는 번역을 지향합니다.

0개의 댓글

관련 채용 정보