Photo By @Chris Fowler
TL;DR
- 타입스크립트는 구조적 타입 시스템을 따르기 때문에 타입이 ‘열려’ 있다.
- 타입스크립트에서 ‘봉인된’ 또는 ‘정확한’ 타입을 만드는 방법에는 명목적 타입과 무공변적 타입이 있다.
- 명목적 타입 시스템은 구조가 같더라도 이름이 다르면 다른 타입으로 구분하는 시스템이다.
- 무공변적 타입은 정확히 동일한, 타입을 요구하는 타입 호환 방식이다.
- 명목적 타입과 무공변적 타입을 통해 객체를 보다 안전하게 순회하면서, 구체적인 타입을 얻을 수 있다.
- 하지만 트릭적인 요소가 포함되므로, 타입스크립트의 구조적 타입 시스템을 따르는 것이 보다 권장된다.
구조적 타입 시스템과 Object.keys
타입스크립트는 구조적 타입 시스템(Structural Type System)을 따른다.
이러한 특성으로 인해 객체의 키를 가지고 객체에 접근할 때 한 가지 문제를 겪게 된다.
1type Person = {2 name: string;3 age: number;4 id: number;5};67declare const me: Person;89Object.keys(me).forEach((key) => {10 // No index signature with a parameter of type 'string' was found on type 'Person'.(7053)11 console.log(me[key]);12});
에러의 원인을 찾다 보면 Object.keys
의 반환 타입이 string[]
이기 때문에 발생한 것을 알 수 있다.
1interface ObjectConstructor {2 //...3 keys(o: object): string[];4 keys(o: {}): string[];5}
Person
타입의 객체에 접근할 수 있는 실제 키는 name | age | id
타입인데, Object.keys
로 얻은 key는 그에 상위 집합인 string
타입이기 때문에 접근할 수 없는 것이다.
그러면 이 메서드의 시그니쳐를 다음과 같이 변경할 순 없을까?
1interface ObjectConstructor {2 // ...3 keys<O extends object>(o: O): (keyof O)[];4}
이렇게 타이핑 했을 때의 문제는 이 이슈 코멘트에서 볼 수 있다.
요약하면 객체는 런타임에 더 많은 속성을 가질 수 있기 때문에, (keyof O)[]
은 좁은 타입이고, string[]
타입이 정확한 타입이라는 것이다.
이러한 상황을 재현한 예제는 다음과 같다.
1interface AB {2 a: string;3 b: string;4}56function someFunction(arg: AB) {7 // 만약 Object.keys가 string[]이 아니라 (keyof AB)[] 타입을 리턴한다면 ...8 return Object.keys(arg) as (keyof AB)[];9}1011const myArgument = {12 a: 'some',13 b: 'thing',14 c: 'unexpected',15};1617// myArgument는 매개변수 타입 AB에 호환된다.18// 따라서, 실제로는 ('a' | 'b' | 'c')[]이 리턴될 것이고, (keyof AB)[] 타입은 오류라는 것을 알 수 있다.19someFunction(myArgument);
아래의 두 글은 구조적 타입 시스템을 따르는 타입스크립트에서 객체를 순회하는 방법을 소개한다.
- https://fettblog.eu/typescript-better-object-keys/
- https://effectivetypescript.com/2020/05/26/iterate-objects/
두 글에서는 Object.entries
를 사용하여 객체를 순회하거나, 객체에 다른 속성이 포함되지 않을 것을 확신할 수 있는 경우에 (keyof O)[]
로 캐스팅하여 사용할 것을 권한다.
하지만 “캐스팅을 사용하지 않고, 객체의 키 타입을 구체적으로 얻을 수 있는 방법은 없을까?” 고민하게 되었고, 무공변적(Invariant) 타입을 사용하는 방법을 알게 되었다.
이 글에서는 무공변적 타입을 사용해 객체의 키 타입을 보다 구체적으로 얻는 방법에 대해 알게 된 내용을 다룬다.
무공변성이란?
먼저 무공변성이란 무엇인지 알아보자.
타입 시스템이 가질 수 있는 타입 호환성 또는 유형 변환 방식에는 공변성(Covariance), 반공변성(Contravariance), 무공변성(Invariance), 이변성(Bivariance)이 있다.
이 중, 무공변성은, 원본 타입의 슈퍼 타입이나 서브 타입도 호환하지 않는 ‘봉인된(sealed)’ 또는 ‘정확한(precise)’ 타입 호환성이다.
런타임에서
Object.seal
로 만들어진 객체와 유사하다고 볼 수 있다.
1interface SuperType {2 foo: number;3}45interface BaseType extends SuperType {6 bar: string;7}89interface SubType extends BaseType {10 baz: boolean;11}1213declare let superTypeValue: SuperType;14declare let baseTypeValue: BaseType;15declare let subTypeValue: SubType;1617// 공변 (타입스크립트에서 변수는 공변이다.)18baseTypeValue = superTypeValue; // 에러!19baseTypeValue = subTypeValue; // 허용2021// 반공변 (타입스크립트에서 함수의 매개변수는 반공변이다.)22baseTypeValue = superTypeValue; // 허용23baseTypeValue = subTypeValue; // 에러!2425// 무공변26baseTypeValue = superTypeValue; // 에러!27baseTypeValue = subTypeValue; // 에러!2829// 이변30baseTypeValue = superTypeValue; // 허용31baseTypeValue = subTypeValue; // 허용
타입스크립트의 유형 호환 규칙
타입스크립트는 크게 두 가지 유형 호환 규칙을 따른다.
- 타입스크립트에서 변수는 공변한다.
- 타입스크립트에서 두 함수의 타입을 비교할 때, 함수의 매개변수는 반공변성을 가진다. (
--strictFunctionTypes
옵션을 사용하는 경우)
따라서, 타입스크립트에서는 1번 규칙에 따라 원본 타입을 만족한다면 추가적인 속성을 가지고 있어도 허용하므로, 런타임에 객체에 더 많은 키가 존재할 수 있는 것이다.
그래서 타입스크립트에서도 ”정확한 타입을 강제한다면, 객체의 키 타입을 구체적으로 알 수 있지 않을까?”하고 생각했고, 이를 만들 수 있는 방법을 찾기 시작했다.
명목적 타입 시스템
정확한 타입을 강제하는 방법 중 하나는 명목적 타입(Nominal Type) 또는 불투명 타입(Opaque Type)으로 불리는 타입을 만들어서 사용하는 것이다.
명목적 타입은 두 변수의 구조가 같더라도 유형의 이름이 다르면 다른 타입으로 구분하는 시스템이다.
명목적 타입을 만드는 방법은 아래와 같이 타입을 구분할 토큰을 unique symbol
프로퍼티로 가지는 Tagged
타입과 Intersect하는 것이다.
1declare const tag: unique symbol;23declare type Tagged<Token> = {4 readonly [tag]: Token;5};67export type Opaque<Type, Token = unknown> = Type & Tagged<Token>;
Opaque 타입에 대한 더 자세한 설명은 여기를 참고
이 명목적 타입을 사용하면 무공변성과 유사하게 동작하도록 만들 수 있다.
1interface SuperType {2 foo: number;3}45interface BaseType extends SuperType {6 bar: string;7}89interface SubType extends BaseType {10 baz: boolean;11}1213declare let superTypeValue: Opaque<SuperType, 'SuperType'>;14declare let baseTypeValue: Opaque<BaseType, 'BaseType'>;15declare let subTypeValue: Opaque<SubType, 'SubType'>;1617baseTypeValue = superTypeValue; // 에러!18baseTypeValue = subTypeValue; // 에러!1920// 명목적 타입으로 만들어주는 브랜드 함수21function createBaseType(base: BaseType): Opaque<BaseType, 'BaseType'> {22 return base as Opaque<BaseType, 'BaseType'>;23}2425baseTypeValue = createBaseType({foo: 123, bar: 'hello'});
그러면 이제 명목적으로 타이핑된 객체의 경우에는, ‘봉인된’, ‘정확한’ 타입이므로 Object.keys
의 리턴 타입을 구체적으로 만들 수 있을 것이다.
1interface AB {2 a: string;3 b: string;4}56type NominalAB = Opaque<AB, 'AB'>;78// 명목적 타입으로 만들어주는 브랜드 함수9function createAB(ab: AB): NominalAB {10 return ab as NominalAB;11}1213function getKeysOfAB(arg: NominalAB): (keyof AB)[] {14 return Object.keys(arg) as (keyof AB)[];15}1617const invalidArgument = {18 a: 'some',19 b: 'thing',20 c: 'unexpected',21};2223const validArgument = createAB({24 a: 'some',25 b: 'thing',26});2728let keys: (keyof AB)[];2930keys = getKeysOfAB(invalidArgument); // 에러!31keys = getKeysOfAB(validArgument);
물론 브랜드 함수 자체는 구조적 타입으로 작성되기 때문에 런타임 에러의 가능성이 없는 것은 아니다.
하지만 브랜드 함수의 특성상 개발자가 주의를 기울이게 하는 효과가 있기 때문에, 보다 안전한 방법이라고 할 수 있다.
1function createAB(ab: AB): NominalAB {2 return ab as NominalAB;3}45interface ABC extends AB {6 c: boolean;7}89declare const abc: ABC;1011createAB(abc); // Okay... But...
하지만 단지 객체의 키를에 대한 타입을 얻기 위해서 모든 타입을 명목적으로 작성해야 한다면 각각의 이름과 브랜드 함수를 모두 만들어 주어야 한다.
명목적 타입이 아닌 무공변적 타입을 타입스크립트에서 만드는 방법은 없을까?
무공변적 타입 만들기
첫 번째 접근
무공변적 타입을 만들기 위한 첫 번째 접근은 위의 명목적 타입을 만드는 방법에서 힌트를 얻었다.
Opaque
타입이 타입을 구분할 토큰을 프로퍼티로 가지는 Tagged
타입과 Intersect하는 것이라면,
무공변적 타입은 ”객체 타입의 키들(keyof O
)을 구분할 토큰으로 사용하면 되지 않을까?” 생각했다.
1declare const tag: unique symbol;23declare type Tagged<Token> = {4 readonly [tag]: Token;5};67export type Opaque<Type, Token = unknown> = Type & Tagged<Token>;89export type InvariantOf<O extends object> = Opaque<O, keyof O>;
이렇게 만든 무공변적 타입은 객체 타입이라는 제약하에 효과가 있었다.
1interface SuperType {2 foo: number;3}45interface BaseType extends SuperType {6 bar: string;7}89interface SubType extends BaseType {10 baz: boolean;11}1213declare let superTypeValue: InvariantOf<SuperType>;14declare let baseTypeValue: InvariantOf<BaseType>;15declare let subTypeValue: InvariantOf<SubType>;1617baseTypeValue = superTypeValue; // 에러!18baseTypeValue = subTypeValue; // 에러!1920// 무공변 타입으로 만들어주는 함수21function invariantOf<O extends object>(base: O): InvariantOf<O> {22 return base as InvariantOf<O>;23}2425baseTypeValue = invariantOf({foo: 123, bar: 'hello'});
하지만 무공변성은 객체 타입 뿐만 아니라, number
타입과 리터럴 1
타입의 관계나 number | string
타입과 string
타입의 관계에서도 적용되어야 하므로 불완전했다.
두 번째 접근
두 번째 방법은 이 글을 통해 알게 되었는데, 위에 서술한 타입스크립트 유형 호환 규칙에 따라 매개변수와 리턴 타입으로 특정 타입을 동시에 가지는 함수의 타입을 사용함으로 무공변적 타입을 만들 수 있다.
1declare const tag: unique symbol;23declare type InvariantProperty<Type> = (arg: Type) => Type;45declare type InvariantSignature<Type> = {6 readonly [tag]: InvariantProperty<Type>;7};89export type InvariantOf<Type> = Type & InvariantSignature<Type>;
즉, InvariantPropety<Type>
은 함수의 매개변수와 리턴 타입으로 모두 Type
을 사용하기 때문에, 함수의 타입을 비교할 때 반공변성을 가지는 매개변수와, 공변성을 가지는 리턴 타입을 모두 만족해야 하기 때문에, 무공변성을 가진 타입이다.
1declare const valueAsNumber: InvariantOf2<number>;2declare const valueAs1: InvariantOf2<1>;34valueAsNumber = valueAs1; // 에러!5valueAs1 = valueAsNumber; // 에러!67declare const valueAsStringOrNumber: InvariantOf2<string | number>;8declare const valueAsString: InvariantOf2<string>;910valueAsStringOrNumber = valueAsString; // 에러!11valueAsString = valueAsStringOrNumber; // 에러!
이제 무공변적으로 타이핑된 객체는 ‘봉인된’, ‘정확한’ 타입이므로 Object.keys
의 리턴 타입을 구체적으로 만들 수 있다.
또한, 명목적 타입과 달리 모든 개별 타입을 위한 브랜드 함수를 만들 필요 없이, 무공변 타입을 만드는 함수 하나만 만들면 된다.
1interface AB {2 a: string;3 b: string;4}56// 무공변적 타입으로 만들어주는 함수7export function invariantOf<Type>(value: Type): InvariantOf<Type> {8 return value as InvariantOf<Type>;9}1011function getKeysOfAB(arg: Invariant<AB>): (keyof AB)[] {12 return Object.keys(arg) as (keyof AB)[];13}1415const invalidArgument = {16 a: 'some',17 b: 'thing',18 c: 'unexpected',19};2021const validArgument = createAB({22 a: 'some',23 b: 'thing',24});2526let keys: (keyof AB)[];2728keys = getKeysOfAB(invalidArgument); // 에러!29keys = getKeysOfAB(validArgument);
무공변 타입을 전역에서 사용하기
타입스크립트는 전역 보강이라는 선언 병합 방법을 제공한다.
전역의 선언을 수정하는 것은 자칫 위험해 보일 수 있지만, 무공변 타입이라는 제약 안에서만 적용되므로 시도해볼 수 있다.
무공변 타입을 사용하는 경우를 위한 오버로드를 다음과 같이 전역 객체에 추가할 수 있다.
1declare global {2 export interface ObjectConstructor {3 getOwnPropertyNames<T extends object>(o: InvariantOf<T>): Array<keyof T>;45 keys<T extends object>(o: InvariantOf<T>): Array<keyof T>;67 entries<T extends object>(o: InvariantOf<T>): Array<[keyof T, T[keyof T]]>;8 }9}
이를 통해 객체를 순회하는 메서드에, 무공변 타입을 사용하는 경우 위의 오버로드 시그니쳐가 선택되어 더 구체적인 타입을 얻을 수 있게 된다.
1interface AB {2 a: string;3 b: string;4}56declare const ab: AB;78Object.keys(ab); // string[]9Object.keys(invariantOf(ab)); // (keyof AB)[]
마치며 …
물론, 이 방법은 타입스크립트의 목표에 포함되는 일반적인 구조적 타입 시스템을 거스르고, 트릭적이어서 친절하지 않은 에러 메시지를 겪게 되는 단점이 있다.
따라서, 구조적 타입 시스템에 익숙해지고, 타입스크립트의 제약 안에서 객체를 순회하는 것이 더 권장된다.
그렇지만 객체의 키 속성을 구체적으로 알기 위해서는 개발자가 어설션해야 되는 타입스크립트의 디자인적 한계 사항에 대한 나름의 방법을 찾을 수 있었던 좋은 경험이었다.
참고자료
객체의 키를 순회하는 방법
- https://fettblog.eu/typescript-better-object-keys/
- https://effectivetypescript.com/2020/05/26/iterate-objects/
- https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208
타입 호환성
- https://flow.org/en/docs/lang/variance/
- https://basarat.gitbook.io/typescript/type-system/type-compatibility#variance
- https://basarat.gitbook.io/typescript/main-1/nominaltyping#using-interfaces
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/seal