Navigate back to the homepage

타입스크립트에서 객체를 "더 안전하게" 순회하는 방법 (feat: 무공변성)

younho9
October 21st, 2021 · 3 min read

Photo By @Chris Fowler

TL;DR

  • 타입스크립트는 구조적 타입 시스템을 따르기 때문에 타입이 ‘열려’ 있다.
  • 타입스크립트에서 ‘봉인된’ 또는 ‘정확한’ 타입을 만드는 방법에는 명목적 타입과 무공변적 타입이 있다.
  • 명목적 타입 시스템구조가 같더라도 이름이 다르면 다른 타입으로 구분하는 시스템이다.
  • 무공변적 타입은 정확히 동일한, 타입을 요구하는 타입 호환 방식이다.
  • 명목적 타입과 무공변적 타입을 통해 객체를 보다 안전하게 순회하면서, 구체적인 타입을 얻을 수 있다.
  • 하지만 트릭적인 요소가 포함되므로, 타입스크립트의 구조적 타입 시스템을 따르는 것이 보다 권장된다.

구조적 타입 시스템과 Object.keys

타입스크립트는 구조적 타입 시스템(Structural Type System)을 따른다.

이러한 특성으로 인해 객체의 키를 가지고 객체에 접근할 때 한 가지 문제를 겪게 된다.

1type Person = {
2 name: string;
3 age: number;
4 id: number;
5};
6
7declare const me: Person;
8
9Object.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}
5
6function someFunction(arg: AB) {
7 // 만약 Object.keys가 string[]이 아니라 (keyof AB)[] 타입을 리턴한다면 ...
8 return Object.keys(arg) as (keyof AB)[];
9}
10
11const myArgument = {
12 a: 'some',
13 b: 'thing',
14 c: 'unexpected',
15};
16
17// myArgument는 매개변수 타입 AB에 호환된다.
18// 따라서, 실제로는 ('a' | 'b' | 'c')[]이 리턴될 것이고, (keyof AB)[] 타입은 오류라는 것을 알 수 있다.
19someFunction(myArgument);

아래의 두 글은 구조적 타입 시스템을 따르는 타입스크립트에서 객체를 순회하는 방법을 소개한다.

두 글에서는 Object.entries 를 사용하여 객체를 순회하거나, 객체에 다른 속성이 포함되지 않을 것을 확신할 수 있는 경우(keyof O)[]로 캐스팅하여 사용할 것을 권한다.

하지만 “캐스팅을 사용하지 않고, 객체의 키 타입을 구체적으로 얻을 수 있는 방법은 없을까?” 고민하게 되었고, 무공변적(Invariant) 타입을 사용하는 방법을 알게 되었다.

이 글에서는 무공변적 타입을 사용해 객체의 키 타입을 보다 구체적으로 얻는 방법에 대해 알게 된 내용을 다룬다.

무공변성이란?

먼저 무공변성이란 무엇인지 알아보자.

타입 시스템이 가질 수 있는 타입 호환성 또는 유형 변환 방식에는 공변성(Covariance), 반공변성(Contravariance), 무공변성(Invariance), 이변성(Bivariance)이 있다.

이 중, 무공변성은, 원본 타입의 슈퍼 타입이나 서브 타입도 호환하지 않는 ‘봉인된(sealed)’ 또는 ‘정확한(precise)’ 타입 호환성이다.

런타임에서 Object.seal로 만들어진 객체와 유사하다고 볼 수 있다.

1interface SuperType {
2 foo: number;
3}
4
5interface BaseType extends SuperType {
6 bar: string;
7}
8
9interface SubType extends BaseType {
10 baz: boolean;
11}
12
13declare let superTypeValue: SuperType;
14declare let baseTypeValue: BaseType;
15declare let subTypeValue: SubType;
16
17// 공변 (타입스크립트에서 변수는 공변이다.)
18baseTypeValue = superTypeValue; // 에러!
19baseTypeValue = subTypeValue; // 허용
20
21// 반공변 (타입스크립트에서 함수의 매개변수는 반공변이다.)
22baseTypeValue = superTypeValue; // 허용
23baseTypeValue = subTypeValue; // 에러!
24
25// 무공변
26baseTypeValue = superTypeValue; // 에러!
27baseTypeValue = subTypeValue; // 에러!
28
29// 이변
30baseTypeValue = superTypeValue; // 허용
31baseTypeValue = subTypeValue; // 허용

타입스크립트의 유형 호환 규칙

타입스크립트는 크게 두 가지 유형 호환 규칙을 따른다.

  1. 타입스크립트에서 변수는 공변한다.
  2. 타입스크립트에서 두 함수의 타입을 비교할 때, 함수의 매개변수는 반공변성을 가진다. (--strictFunctionTypes 옵션을 사용하는 경우)

따라서, 타입스크립트에서는 1번 규칙에 따라 원본 타입을 만족한다면 추가적인 속성을 가지고 있어도 허용하므로, 런타임에 객체에 더 많은 키가 존재할 수 있는 것이다.

그래서 타입스크립트에서도 ”정확한 타입을 강제한다면, 객체의 키 타입을 구체적으로 알 수 있지 않을까?”하고 생각했고, 이를 만들 수 있는 방법을 찾기 시작했다.

명목적 타입 시스템

정확한 타입을 강제하는 방법 중 하나는 명목적 타입(Nominal Type) 또는 불투명 타입(Opaque Type)으로 불리는 타입을 만들어서 사용하는 것이다.

명목적 타입은 두 변수의 구조가 같더라도 유형의 이름이 다르면 다른 타입으로 구분하는 시스템이다.

명목적 타입을 만드는 방법은 아래와 같이 타입을 구분할 토큰을 unique symbol 프로퍼티로 가지는 Tagged 타입과 Intersect하는 것이다.

1declare const tag: unique symbol;
2
3declare type Tagged<Token> = {
4 readonly [tag]: Token;
5};
6
7export type Opaque<Type, Token = unknown> = Type & Tagged<Token>;

Opaque 타입에 대한 더 자세한 설명은 여기를 참고

이 명목적 타입을 사용하면 무공변성과 유사하게 동작하도록 만들 수 있다.

1interface SuperType {
2 foo: number;
3}
4
5interface BaseType extends SuperType {
6 bar: string;
7}
8
9interface SubType extends BaseType {
10 baz: boolean;
11}
12
13declare let superTypeValue: Opaque<SuperType, 'SuperType'>;
14declare let baseTypeValue: Opaque<BaseType, 'BaseType'>;
15declare let subTypeValue: Opaque<SubType, 'SubType'>;
16
17baseTypeValue = superTypeValue; // 에러!
18baseTypeValue = subTypeValue; // 에러!
19
20// 명목적 타입으로 만들어주는 브랜드 함수
21function createBaseType(base: BaseType): Opaque<BaseType, 'BaseType'> {
22 return base as Opaque<BaseType, 'BaseType'>;
23}
24
25baseTypeValue = createBaseType({foo: 123, bar: 'hello'});

그러면 이제 명목적으로 타이핑된 객체의 경우에는, ‘봉인된’, ‘정확한’ 타입이므로 Object.keys의 리턴 타입을 구체적으로 만들 수 있을 것이다.

1interface AB {
2 a: string;
3 b: string;
4}
5
6type NominalAB = Opaque<AB, 'AB'>;
7
8// 명목적 타입으로 만들어주는 브랜드 함수
9function createAB(ab: AB): NominalAB {
10 return ab as NominalAB;
11}
12
13function getKeysOfAB(arg: NominalAB): (keyof AB)[] {
14 return Object.keys(arg) as (keyof AB)[];
15}
16
17const invalidArgument = {
18 a: 'some',
19 b: 'thing',
20 c: 'unexpected',
21};
22
23const validArgument = createAB({
24 a: 'some',
25 b: 'thing',
26});
27
28let keys: (keyof AB)[];
29
30keys = getKeysOfAB(invalidArgument); // 에러!
31keys = getKeysOfAB(validArgument);

물론 브랜드 함수 자체는 구조적 타입으로 작성되기 때문에 런타임 에러의 가능성이 없는 것은 아니다.

하지만 브랜드 함수의 특성상 개발자가 주의를 기울이게 하는 효과가 있기 때문에, 보다 안전한 방법이라고 할 수 있다.

1function createAB(ab: AB): NominalAB {
2 return ab as NominalAB;
3}
4
5interface ABC extends AB {
6 c: boolean;
7}
8
9declare const abc: ABC;
10
11createAB(abc); // Okay... But...

하지만 단지 객체의 키를에 대한 타입을 얻기 위해서 모든 타입을 명목적으로 작성해야 한다면 각각의 이름과 브랜드 함수를 모두 만들어 주어야 한다.

명목적 타입이 아닌 무공변적 타입을 타입스크립트에서 만드는 방법은 없을까?

무공변적 타입 만들기

첫 번째 접근

무공변적 타입을 만들기 위한 첫 번째 접근은 위의 명목적 타입을 만드는 방법에서 힌트를 얻었다.

Opaque 타입이 타입을 구분할 토큰을 프로퍼티로 가지는 Tagged 타입과 Intersect하는 것이라면,

무공변적 타입은 ”객체 타입의 키들(keyof O)을 구분할 토큰으로 사용하면 되지 않을까?” 생각했다.

1declare const tag: unique symbol;
2
3declare type Tagged<Token> = {
4 readonly [tag]: Token;
5};
6
7export type Opaque<Type, Token = unknown> = Type & Tagged<Token>;
8
9export type InvariantOf<O extends object> = Opaque<O, keyof O>;

이렇게 만든 무공변적 타입은 객체 타입이라는 제약하에 효과가 있었다.

1interface SuperType {
2 foo: number;
3}
4
5interface BaseType extends SuperType {
6 bar: string;
7}
8
9interface SubType extends BaseType {
10 baz: boolean;
11}
12
13declare let superTypeValue: InvariantOf<SuperType>;
14declare let baseTypeValue: InvariantOf<BaseType>;
15declare let subTypeValue: InvariantOf<SubType>;
16
17baseTypeValue = superTypeValue; // 에러!
18baseTypeValue = subTypeValue; // 에러!
19
20// 무공변 타입으로 만들어주는 함수
21function invariantOf<O extends object>(base: O): InvariantOf<O> {
22 return base as InvariantOf<O>;
23}
24
25baseTypeValue = invariantOf({foo: 123, bar: 'hello'});

하지만 무공변성은 객체 타입 뿐만 아니라, number 타입과 리터럴 1 타입의 관계나 number | string 타입과 string 타입의 관계에서도 적용되어야 하므로 불완전했다.

두 번째 접근

두 번째 방법은 이 글을 통해 알게 되었는데, 위에 서술한 타입스크립트 유형 호환 규칙에 따라 매개변수와 리턴 타입으로 특정 타입을 동시에 가지는 함수의 타입을 사용함으로 무공변적 타입을 만들 수 있다.

1declare const tag: unique symbol;
2
3declare type InvariantProperty<Type> = (arg: Type) => Type;
4
5declare type InvariantSignature<Type> = {
6 readonly [tag]: InvariantProperty<Type>;
7};
8
9export type InvariantOf<Type> = Type & InvariantSignature<Type>;

즉, InvariantPropety<Type>은 함수의 매개변수와 리턴 타입으로 모두 Type 을 사용하기 때문에, 함수의 타입을 비교할 때 반공변성을 가지는 매개변수와, 공변성을 가지는 리턴 타입을 모두 만족해야 하기 때문에, 무공변성을 가진 타입이다.

1declare const valueAsNumber: InvariantOf2<number>;
2declare const valueAs1: InvariantOf2<1>;
3
4valueAsNumber = valueAs1; // 에러!
5valueAs1 = valueAsNumber; // 에러!
6
7declare const valueAsStringOrNumber: InvariantOf2<string | number>;
8declare const valueAsString: InvariantOf2<string>;
9
10valueAsStringOrNumber = valueAsString; // 에러!
11valueAsString = valueAsStringOrNumber; // 에러!

이제 무공변적으로 타이핑된 객체는 ‘봉인된’, ‘정확한’ 타입이므로 Object.keys의 리턴 타입을 구체적으로 만들 수 있다.

또한, 명목적 타입과 달리 모든 개별 타입을 위한 브랜드 함수를 만들 필요 없이, 무공변 타입을 만드는 함수 하나만 만들면 된다.

1interface AB {
2 a: string;
3 b: string;
4}
5
6// 무공변적 타입으로 만들어주는 함수
7export function invariantOf<Type>(value: Type): InvariantOf<Type> {
8 return value as InvariantOf<Type>;
9}
10
11function getKeysOfAB(arg: Invariant<AB>): (keyof AB)[] {
12 return Object.keys(arg) as (keyof AB)[];
13}
14
15const invalidArgument = {
16 a: 'some',
17 b: 'thing',
18 c: 'unexpected',
19};
20
21const validArgument = createAB({
22 a: 'some',
23 b: 'thing',
24});
25
26let keys: (keyof AB)[];
27
28keys = getKeysOfAB(invalidArgument); // 에러!
29keys = getKeysOfAB(validArgument);

무공변 타입을 전역에서 사용하기

타입스크립트는 전역 보강이라는 선언 병합 방법을 제공한다.

전역의 선언을 수정하는 것은 자칫 위험해 보일 수 있지만, 무공변 타입이라는 제약 안에서만 적용되므로 시도해볼 수 있다.

무공변 타입을 사용하는 경우를 위한 오버로드를 다음과 같이 전역 객체에 추가할 수 있다.

1declare global {
2 export interface ObjectConstructor {
3 getOwnPropertyNames<T extends object>(o: InvariantOf<T>): Array<keyof T>;
4
5 keys<T extends object>(o: InvariantOf<T>): Array<keyof T>;
6
7 entries<T extends object>(o: InvariantOf<T>): Array<[keyof T, T[keyof T]]>;
8 }
9}

이를 통해 객체를 순회하는 메서드에, 무공변 타입을 사용하는 경우 위의 오버로드 시그니쳐가 선택되어 더 구체적인 타입을 얻을 수 있게 된다.

1interface AB {
2 a: string;
3 b: string;
4}
5
6declare const ab: AB;
7
8Object.keys(ab); // string[]
9Object.keys(invariantOf(ab)); // (keyof AB)[]

마치며 …

물론, 이 방법은 타입스크립트의 목표에 포함되는 일반적인 구조적 타입 시스템을 거스르고, 트릭적이어서 친절하지 않은 에러 메시지를 겪게 되는 단점이 있다.

따라서, 구조적 타입 시스템에 익숙해지고, 타입스크립트의 제약 안에서 객체를 순회하는 것이 더 권장된다.

그렇지만 객체의 키 속성을 구체적으로 알기 위해서는 개발자가 어설션해야 되는 타입스크립트의 디자인적 한계 사항에 대한 나름의 방법을 찾을 수 있었던 좋은 경험이었다.

참고자료

객체의 키를 순회하는 방법

타입 호환성

명목적 타입(또는 불투명 타입)

More articles from younho9

ESLint 설정 공유하기

ESLint 설정을 한 곳에서 관리하자.

October 6th, 2021 · 3 min read

프리티어(Prettier) 설정 공유하기

모든 프로젝트의 프리티어 설정을 일관되게 유지하자.

September 28th, 2021 · 2 min read
© 2020–2021 younho9
Link to $https://github.com/younho9Link to $https://www.instagram.com/younho_9/