동형 매핑 타입(homomorphic mapped type)이 도대체 뭔가요?

Jung Han
14 min readJan 17, 2024

--

원문: https://andreasimonecosta.dev/posts/what-the-heck-is-a-homomorphic-mapped-type/

도입

예전에 처음으로 타입스크립트 핸드북을 통해 “동형”이라는 용어를 봤을 때 저는 좀 모호하게 느껴졌습니다. 핸드북에 나온 몇 가지 예시 매핑 타입을 살펴본 후에도, 여전히 “동형”이 무엇을 의미하는지 확실히 이해되지 않았습니다.

핸드북은 이런 매핑 예시를 나열한 뒤,

type Nullable<T> = { [P in keyof T]: T[P] | null };
type Partial<T> = { [P in keyof T]?: T[P] };

이렇게 설명합니다.

이러한 예시에서 프로퍼티 목록은 keyof T이고 결과 타입은 T[P]의 일부 변형입니다. 이것은 매핑된 타입을 일반적으로 사용하기 위한 좋은 템플릿입니다. 왜냐하면 이러한 종류의 변환은 동형이며, 이는 매핑이 T의 프로퍼티에만 적용되고 다른 프로퍼티에는 적용되지 않음을 의미합니다.

바로 뒤이어서, Pick<T, K extends keyof T> = { [P in K]: T[P]; }도 동형이지만 Record는 동형이 아니라고 설명했습니다.

Readonly, Partial, Pick은 동형이지만 Record는 그렇지 않습니다. Record가 동형이 아니라는 한 가지 단서는 프로퍼티를 복사할 입력 타입을 받지 않는다는 것입니다. 동형이 아닌 타입은 본질적으로 새로운 속성을 생성하는 것입니다.

”동형”이라는 용어는 수학을 뿌리로 둔 다소 과장된 표현이지만, 기본적으로 이 종류의 매핑된 타입은 원래 타입의 구조를 그대로 유지한다는 의미입니다. 실제로 타입스크립트 위키에서는 다음과 같이 말합니다.

T가 타입 매개변수인 { [K in keyof T]: U }로 선언된 매핑된 타입은 동형 매핑된 타입으로 알려져 있으며, 이는 매핑된 타입이 T의 구조를 보존하는 함수임을 의미합니다.

돌이켜보면, 타입 시스템에 익숙해진 후에는 핸드북의 설명이 더 와닿게 되었습니다. 하지만 현재로서는 최신의 완전한 정의가 없습니다. 새로운 핸드북은 동형이라는 용어를 언급조차 하지 않지만 소스 코드에는 나타납니다.

저는 전체 그림을 알지 못하는 것에 지쳤고, 결국 컴파일러를 열고 동형 매핑된 타입이 도대체 무엇인지 한 번에 알아내려고 노력했습니다.

컴파일러 내부에서는

getHomomorphicTypeVariable

여기 제 질문에 답을 하는 데 도움이 되는 함수가 있습니다.

function getHomomorphicTypeVariable(type: MappedType) {
const constraintType = getConstraintTypeFromMappedType(type);
if (constraintType.flags & TypeFlags.Index) {
const typeVariable = getActualTypeVariable((constraintType as IndexType).type);
if (typeVariable.flags & TypeFlags.TypeParameter) {
return typeVariable as TypeParameter;
}
}
return undefined;
}

매핑된 타입 { [P in C]: ... }는 제약 조건 C가 단순히 keyof T일 때 동형이며, 여기서 T는 타입 변수여야 합니다. 이는 각각 TypeFlags.IndexTypeFlags.TypeParameter 플래그로 표시됩니다. 타입 변수는 어디에서 오는 것일까요? 입력으로 선언하거나 infer 키워드를 사용하여 추론할 수 있습니다. 따라서 타입스크립트가 더 이상 동형으로 간주하지 않는 Pick를 제외하고는 이전 핸드북의 예제가 모두 적합합니다.

그렇다면 동형 매핑 타입은 어떤 프로퍼티를 가지고 있을까요? 그리고 as절은 무엇일까요? as절을 사용하면 키의 이름을 바꾸거나 제거할 수 있으며 이론적으로는 객체의 구조를 변경할 수 있습니다.

instantiateMappedType

이 함수는 매핑된 타입을 인스턴스화해야 할 때 사용합니다.

function instantiateMappedType(type: MappedType, mapper: TypeMapper, aliasSymbol?: Symbol, aliasTypeArguments?: readonly Type[]): Type {
// 동형 매핑된 타입 { [P in keyof T]: X }에서 T가 일부 타입 변수인 경우 매핑 작업은
// 다음과 같이 T에 따라 달라집니다.
// * T가 원시 타입인 경우 매핑이 수행되지 않으며 결과는 단순히 T입니다.
// * T가 유니온 타입인 경우 유니온에 매핑된 타입을 분산(distribute)합니다.
// * T가 배열인 경우 요소 타입이 변환된 배열로 매핑합니다.
// * T가 튜플인 경우 요소 타입이 변환된 튜플로 매핑합니다.
// * 그렇지 않으면 각 속성의 타입이 변환된 객체 타입으로 매핑합니다.
// 예를 들어, T가 유니온 타입 A | B로 인스턴스화 될 때
// { [P in keyof A]: X } | { [P in keyof B]: X }를 생성하고
// T가 유니온 타입 A | undefined로 인스턴스화 될 때 { [P in keyof A]: X } | undefined를 생성합니다.
const typeVariable = getHomomorphicTypeVariable(type);
if (typeVariable) {
const mappedTypeVariable = instantiateType(typeVariable, mapper);
if (typeVariable !== mappedTypeVariable) {
return mapTypeWithAlias(
getReducedType(mappedTypeVariable),
t => {
if (t.flags & (TypeFlags.AnyOrUnknown | TypeFlags.InstantiableNonPrimitive | TypeFlags.Object | TypeFlags.Intersection) && t !== wildcardType && !isErrorType(t)) {
if (!type.declaration.nameType) {
let constraint;
if (
isArrayType(t) || t.flags & TypeFlags.Any && findResolutionCycleStartIndex(typeVariable, TypeSystemPropertyName.ImmediateBaseConstraint) < 0
&& (constraint = getConstraintOfTypeParameter(typeVariable)) && everyType(constraint, isArrayOrTupleType)
) {
return instantiateMappedArrayType(t, type, prependTypeMapping(typeVariable, t, mapper));
}
if (isGenericTupleType(t)) {
return instantiateMappedGenericTupleType(t, type, typeVariable, mapper);
}
if (isTupleType(t)) {
return instantiateMappedTupleType(t, type, prependTypeMapping(typeVariable, t, mapper));
}
}
return instantiateAnonymousType(type, prependTypeMapping(typeVariable, t, mapper));
}
return t;
},
aliasSymbol,
aliasTypeArguments,
);
}
}

// 인스턴스화 제약 조건 타입이 와일드카드 타입인 경우 와일드카드 타입을 반환합니다.
return instantiateType(getConstraintTypeFromMappedType(type), mapper) === wildcardType ? wildcardType : instantiateAnonymousType(type, mapper, aliasSymbol, aliasTypeArguments);
}

여기서 중요한 점은 동형 매핑된 타입이 특별한 방식으로 처리되며, 첫 번째 if 문을 살펴보면 이를 관찰할 수 있다는 것입니다. 주석은 이러한 타입의 몇 가지 특수한 프로퍼티를 이해하는 데 도움이 됩니다.

  1. 동형 매핑된 타입이 원시 타입에 적용되면 결과는 기본 타입 그 자체가 됩니다.
HMT<1> = 1
HMT<string> = string

2. 동형 매핑된 타입이 유니온 타입에 적용되면 결과는 유니온의 각 멤버에 적용된 매핑된 타입의 유니온이 됩니다. (그래서 타입스크립트는 종종 동형 매핑된 타입을 분산적(distributive) 이라고 부릅니다)

HMT<A | B> = HTM<A> | HTM<B>

3. 동형 매핑된 타입이 배열에 적용되면 결과는 여전히 배열이지만 요소 타입은 매핑된 타입의 논리에 의해 변환됩니다.

type HMT<T> = { [P in keyof T]: F<T[P]> }

HMT<A[]> = F<A>[]

4. 동형 매핑된 타입이 튜플에 적용되면 결과는 여전히 튜플이지만 요소 타입은 매핑된 타입의 논리에 따라 변환됩니다.

type HMT<T> = { [P in keyof T]: F<T[P]> }

HMT<[A, B, C]> = [F<A>, F<B>, F<C>]

기본적으로 as절이 없는 동형 매핑된 타입은 배열(튜플) 타입의 숫자형(number | `${number}`) 키만 반복하고 다른 키는 그대로 유지합니다. 따라서 매핑된 타입 로직은 요소 타입에만 적용됩니다.

튜플과 배열 타입의 보존은 !type.declaration.nameType의 경우에만 발생합니다. as절을 사용하는 경우 type.declaration.nameType은 템플릿 리터럴 또는 조건문과 같이 절 뒤에 오는 모든 것을 포함합니다. 일부 키의 이름을 바꾸거나 필터링하는 경우 일부 또는 모든 숫자형 키가 손실될 수 있기 때문에 튜플 및 배열 타입이 손실되는 것은 합리적입니다. as절을 사용하면 동형 매핑된 타입도 현재 배열(튜플) 타입의 모든 키를 반복하지만 이는 곧 변경될 수 있습니다.

따라서, as절을 사용한다고 해서 매핑된 타입이 동형이 아니게 되는 것은 아닙니다. 단지 튜플과 배열 타입을 보존하지 않을 뿐입니다.

resolveMappedTypeMembers와 getModifiersTypeFromMappedType

간단히 말해서, { [P in keyof T]: ... } 형식의 모든 매핑된 타입은 T가 타입 변수이든 아니든 원래 타입 T의 수정자(modifiers)를 유지할 수 있으며, 이를 수정자 타입이라고 합니다. 모든 동형 매핑된 타입은 이 형식을 따르기 때문에 수정자를 유지합니다.

type HMT<T> = { [P in keyof T]: F<T[P]> }

HMT<{ readonly a: A, b?: B }> = { readonly a: F<A>, b?: F<B> }

매핑된 타입이 { [P in C]: ... } 형식을 가지며 C가 타입 매개변수이고 C의 제약이 keyof T이면 수정자 타입은 T입니다. 이렇게 하면 Pick과 같은 유틸리티 타입이 동형이 아니더라도 원래 타입의 수정자를 보존할 수 있습니다.

type Pick<T, K extends keyof T> = { [P in K]: T[P]; }

Pick<{ readonly a: A, b?: B }, "a"> = { readonly a: A }

게다가 동형 매핑된 타입은 원래 프로퍼티와 파생된 프로퍼티 간의 심링크(symlink)도 보존할 수 있습니다. 심링크는 IDE에서 심볼 탐색(예: “정의로 이동”)을 가능하게 합니다. 이 프로퍼티는 동형 매핑된 타입에만 국한되지 않습니다. 수정자를 보존할 수 있다면 링크를 유지할 가능성도 고려중입니다.

다음 코드는 resolveMappedTypeMembers에서 가져왔습니다.

// 작업...
const shouldLinkPropDeclarations = getMappedTypeNameTypeKind(mappedType) !== MappedTypeNameTypeKind.Remapping;
const modifiersType = getModifiersTypeFromMappedType(type); // 일부 세부 사항 스킵

// 다른 작업...
const modifiersProp = something_something(modifiersType, ...); // 다른 세부 사항 스킵
// 추가 다른 작업...

if (modifiersProp) {
prop.links.syntheticOrigin = modifiersProp;
prop.declarations = shouldLinkPropDeclarations ? modifiersProp.declarations : undefined;
}

따라서 모든 것은 shouldLinkPropDeclarations의 값을 중심으로 이루어집니다. 이 플래그는 키 리매핑을 위해 as 절을 사용하는 경우에만 false입니다. 이 경우 링크가 손실됩니다. 키 필터링을 위해 as절만 사용하거나 as절을 전혀 사용하지 않는 경우 modifiersProp이 falsy가 아닌 경우 링크가 유지됩니다.

inferFromObjectTypes

역(reverse) 매핑된 타입에 대해 들어본 적이 있나요? 그렇지 않다면 2023년 TypeScript Congress에서 Mateusz Burzyński의 멋진 Infer multiple things at once with reverse mapped types. 강연을 확인해 보세요.

함수 전체를 게시하지는 않겠습니다. 워낙 방대하니까요. 그러나 매핑된 타입의 동작을 되돌릴 수 있는 가능성에 관한 본질은 다음과 같습니다.

if (getObjectFlags(target) & ObjectFlags.Mapped && !(target as MappedType).declaration.nameType) {
const constraintType = getConstraintTypeFromMappedType(target as MappedType);
if (inferToMappedType(source, target as MappedType, constraintType)) {
return;
}
}

다시 한번 정리해보면 !(target as MappedType).declaration.nameType이 있어 as절을 사용하는 경우 되돌리기를 방지합니다. 동형은 일부 비동형 매핑된 타입도 되돌릴 수 있기 때문에 되돌리기의 절대적인 요구 사항은 아니지만, as절이 없으면 타입스크립트가 되돌리기를 수행할 수 있다는 것을 나타내는 좋은 지표가 됩니다.

주의: 해당 기능은 이 PR 덕분에 곧 개선될 예정입니다. 매핑된 타입을 필터링하는 것이 매핑된 타입의 이름을 바꾸는 것보다 더 쉽게 되돌릴 수 있으므로 일부 키를 필터링하기 위해 as 절을 사용하는 경우 더 이상 큰 문제가 되지 않을 수 있습니다.

결론

결론적으로 동형 매핑된 타입은 { [K in keyof T (as ...)]: ... } 형식의 타입이며, 여기서 T는 타입 변수이고 괄호는 as 절이 선택 사항임을 나타냅니다. as 절이 없는 동형 매핑된 타입은 특별한 속성을 자랑하는 가장 뛰어난 타입입니다. 또한, as 절이 있는 타입은 그렇게 나쁘지는 않지만 몇 가지 기능이 적습니다. 매핑된 타입이 동형이 아니더라도 수정자를 보존하고 원래 타입에 대한 심링크를 가지며 되돌릴 수 있는 가능성과 같은 일부 속성을 여전히 가질 수 있습니다.

매핑된 유형을 만들 때는 동형을 목표로 하세요.

이 글도 살펴보세요

--

--