타입스크립트에서는 타입 추론을 활용하는 것이 권장되는 좋은 방법입니다.
이러한 맥락에서 타입 단언(assertion)의 사용을 최소화하려 노력하던 중, Object.keys()
의 반환 타입이 string[]
인 점에 대해 의문이 생겼습니다. 처음에는 Object.keys()
가 객체의 키들을 반환하므로 keyof
타입의 배열을 반환하는 것이 더 적절하지 않을까 생각했습니다.
하지만 이 문제를 공부해본 결과, 타입스크립트의 핵심 디자인 목표인 "JavaScript 코드의 런타임 동작 보존"이라는 관점에서 string[]
이 더 적합한 타입이라고 생각하게 되었습니다.
이 글에서는 이러한 결론에 도달한 과정과 그 내용들에 관해 설명하고자 합니다.
💡 관련된 추가 내용들은 문서 하단에서 확인하실 수 있습니다.
먼저 제가 궁금했던 상황을 코드로 살펴보겠습니다.
const a: {} = {};
const b: object = {};
const c: {x:string, y:number} = { x: '', y: 2 };
Object.keys(a) // string[]
Object.keys(b) // string[]
Object.keys(c) // string[]
위 예시처럼 객체의 타입과 관계없이 Object.keys()
는 항상 string[]
을 반환합니다. 이러한 타입 정의가 어떻게 이루어졌는지 타입스크립트 라이브러리 파일을 통해 확인해보았습니다.
타입스크립트의 tsconfig
에서는 lib
옵션을 통해 사용할 라이브러리를 지정할 수 있으며, 각 라이브러리마다 타입 정의 방식이 조금씩 다릅니다. Object.keys()
의 경우 두 개의 라이브러리 파일에서 다음과 같이 정의되어 있었습니다:
// lib.es5.d.ts
keys(o: object): string[];
// lib.es2015.d.ts
keys(o: {}): string[];
매개변수의 타입은 다르지만, 두 정의 모두 반환 타입이 string[]
로 동일합니다. 즉, 어떤 객체를 인자로 전달하더라도 항상 문자열 배열을 반환하도록 설계되어 있습니다.
이러한 타입 정의 확인한 후, 제가 대안으로 생각했던 keyof
타입에 대해 알아보도록 하겠습니다.
타입스크립트의 keyof
연산자는 객체 타입에서 키들의 유니온 타입을 추출합니다. Object.keys()
의 반환 타입과 비교해보기 위해 앞선 예제에 keyof
를 적용해보겠습니다:
const a: {} = {};
const b: object = {};
const c: {x:string, y:number} = { x: '', y: 2 };
Object.keys(a) // string[]
type AKeys = Array<keyof typeof a>; // never[]
Object.keys(b) // string[]
type BKeys = Array<keyof typeof b>; // never[]
Object.keys(c) // string[]
type CKeys = Array<keyof typeof c>; // ("x" | "y")[]
keyof
를 사용했을 때, 각 객체의 타입에 따라 더 구체적인 타입 정보를 얻을 수 있습니다. 특히 c
객체의 경우 키들이 정확히 "x" | "y"
유니온 타입의 배열로 추론됩니다.
처음에는 이처럼 더 정확한 타입 정보를 제공하는 keyof
방식이 Object.keys()
의 반환 타입으로 더 적절해 보였습니다.
이러한 의문을 해결하기 위해 검색을 통해 더 많은 내용들을 찾을 수 있었습니다.
타입스크립트 개발팀의 공식 입장을 확인하기 위해 GitHub 이슈들을 살펴보았습니다. 그 중 특히 주목할 만한 것은 "Object.keys
의 반환 타입이 잘못되었다"는 이슈(#12870)에 대한 기여자의 답변이었습니다:
"이는 의도된 동작입니다. 타입스크립트의 타입들은 열린 구조(open ended)입니다. 따라서
keyof
가 반환하는 것은 런타임에서 실제로 존재할 수 있는 모든 속성들의 부분집합일 수 있습니다."
이 답변에서 중요한 개념은 "열린 구조"입니다. 열린 구조란 타입이 명시적으로 선언된 속성 외에도 추가적인 속성을 가질 수 있음을 의미합니다.
이러한 설계 철학을 더 깊이 이해하기 위해서는 타입스크립트의 핵심 개념인 "duck typing" 또는 "structural typing"에 대해 알아볼 필요가 있습니다.
이는 타입스크립트가 채택하고 있는 Structural Type System의 근간이 핵심 개념입니다.
타입스크립트의 핵심 원칙 중 하나는 타입 검사가 값의 구조(structure)에 기반한다는 점입니다. 실제 예시를 통해 살펴보겠습니다:
interface Person {
name: string;
age: number;
}
const person: Person = {
name: "Kim",
age: 30,
}
const personKeys = Object.keys(person) // string[]
type PersonKeys = Array<keyof typeof person>; // (keyof Person)[]
하지만 타입스크립트의 구조적 타이핑(Structural Typing)으로 인해, 런타임에서는 정의된 타입보다 더 많은 속성을 가질 수 있습니다:
interface Person {
name: string;
age: number;
}
const fullPerson = {
name: 'Kim',
age: 30,
address: 'Seoul',
phone: '010-1234-5678',
};
const person2: Person = fullPerson; // 정상 동작
const person2Keys = Object.keys(person2) // string[]
type Person2Keys = Array<keyof typeof person2>; // (keyof Person)[]
위 예시에서 fullPerson
은 Person
인터페이스에 정의되지 않은 추가 속성(address
, phone
)을 가지고 있지만, 구조적 타이핑에 의해 Person
타입으로 할당이 가능합니다.
런타임에서 Object.keys(person2)
는 이러한 추가 속성들도 포함하게 됩니다.
이것이 바로 Object.keys
가 string[]
을 반환하는 이유입니다.
컴파일 시점의 타입 정보(keyof
)보다 런타임에 실제로 존재할 수 있는 속성이 더 많을 수 있기 때문입니다.
여기서 흥미로운 의문이 하나 생깁니다: 객체의 키로 string
뿐만 아니라 symbol
도 사용할 수 있는데, 왜 Object.keys
는 symbol
키를 반환하지 않을까요?
Object.keys()
자바스크립트에서 객체의 키는 symbol
을 제외한 모든 타입이 문자열로 자동 변환됩니다:
1
→ "1"
)true
→ "true"
)null
→ "null"
undefined
→ "undefined"
toString()
메서드 호출 결과)따라서 객체의 키로 실제 사용 가능한 타입은 string
과 symbol
뿐입니다. 그런데 Object.keys()
는 왜 symbol
타입의 키를 반환하지 않을까요?
이는 symbol
이 ES2015(ES6)에서 도입될 때의 설계 의도 때문입니다. symbol
키는 일반적인 열거 메서드에서 제외되도록 설계되었으며, 대신 전용 메서드를 통해 접근해야 합니다:
const sym = Symbol('mySymbol');
const obj = {
normalKey: 'value',
[sym]: 'symbol value'
};
console.log(Object.keys(obj)); // ['normalKey']
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(mySymbol)]
// 모든 키를 조회하려면
console.log(Reflect.ownKeys(obj)); // ['normalKey', Symbol(mySymbol)]
이처럼 symbol
키에 접근하기 위해서는 Object.getOwnPropertySymbols()
를 사용하거나, 모든 키를 조회하고 싶다면 Reflect.ownKeys()
를 사용할 수 있습니다.
Object.keys
의 실용적인 활용 방법지금까지의 분석을 바탕으로, Object.keys
를 실제로 어떻게 활용할 수 있는지 살펴보겠습니다. Stack Overflow에서는 주로 다음 두 가지 접근 방법이 권장되고 있습니다:
function isKeyOf<T extends object>(obj: T): (key: PropertyKey) => key is keyof T {
return (key: PropertyKey): key is keyof T => key in obj;
}
const obj = { a: 1, b: 2 };
const keys = Object.keys(obj).filter(isKeyOf(obj)); // ('a' | 'b')[]
이 방법은 in
연산자를 활용해 런타임에 키의 존재 여부를 검증합니다.
타입 단언보다 안전한 방법으로, 실제 존재하지 않는 키에 대한 접근을 방지할 수 있습니다.
const getKeys = <T>(obj: T) => Object.keys(obj) as Array<keyof T>
const obj = {a: 1, b: 2}
const objKeys = getKeys(obj) // ("a" | "b")[]
이 방법은 제네릭 유틸리티 함수와 타입 단언을 사용하는 접근법입니다.
하지만 주의할 점이 있습니다:
as
)은 타입스크립트의 타입 체크를 우회하므로 타입 안전성을 보장할 수 없습니다.따라서:
Object.keys를 통해 타입스크립트에 대해 깊이 있게 탐구하면서 많은 것을 배웠습니다.
타입스크립트의 디자인 원칙과 FAQ를 살펴보며 이 언어가 추구하는 방향에 관해 알 수 있었습니다.
앞으로 라이브러리나 프로그래밍 언어를 공부할 때는 공식 문서, 특히 디자인 목표를 꼭 찾아서 보는 것이 중요하다고 느끼게 되었습니다.
이 과정을 거치면서 타입스크립트에 대해 더 배울 수 있어서 좋은 경험이었습니다.
감사합니다.
출처:
[공식 문서/스펙]
타입스크립트 디자인 목표
https://github.com/microsoft/TypeScript/wiki/TypeScript-Design-Goals
excess-property-checks
https://www.typescriptlang.org/docs/handbook/2/objects.html#excess-property-checks
Object key
https://tc39.es/ecma262/#sec-topropertykey
[관련 이슈/토론]
Object.keys has wrong return type from 2.1 #12870
https://github.com/Microsoft/TypeScript/issues/12870
Exact Types(open)
https://github.com/microsoft/TypeScript/issues/12936
Redefine Object.keys to use keyof
https://github.com/microsoft/TypeScript/issues/20503
typescript-object-keys-return-string
https://stackoverflow.com/questions/52856496/typescript-object-keys-return-string
Object.keys() types refinement, and Object.entries() types bugfix
https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208
너무 좋은 글이네요. 감사합니다.