원문: 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
타입은 연관된 두 프로퍼티 v
와 f
를 가지는 레코드의 union입니다. v
의 타입은 항상 f
의 매개변수의 타입과 같습니다.
processRecord
함수에서는 레코드의 v
를 rec.f(rec.v)
로 호출하여 이 상관관계를 활용하고 있습니다. 그러나 이 코드는 type checker의 관점에서는 안전하지 않습니다. Type checker의 관점에서는 rec.v
의 타입이 string | number | boolean
이고, rec.f
의 타입이 (v: never) => void
입니다. v
가 never
인 것은 string & number & boolean
의 교집합으로 계산되기 때문입니다. Type checker는 "어떤" 레코드의 v
를 "어떤" 레코드의 f
에 전달할 수 있는지 확인하려 하기 때문에 v
와 f
가 같은 레코드에서 온다는 점은 고려하지 않습니다.
이제 이런 패턴의 타입을 작성할 때 사용할 수 있는 접근법을 소개하겠습니다. 이 접근법의 핵심은 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 원칙을 훌륭하게 만족합니다.