개발 기록 남기기✍️

[TypeScript] Object.keys()의 타입 좁히기 본문

Front-End/TypeScript

[TypeScript] Object.keys()의 타입 좁히기

너해동물원 2024. 4. 16. 00:27

🚨 마주친 문제

 

Record<'a' | 'b', string> 형태의 객체를 만들어서 Object.keys() 또는 Object.entries()를 실행했을 때 key의 타입은 ('a' | 'b')[] 형태가 아닌 string[]이 됩니다.

 

이 때문에 다음과 같이 Object.keys()로 추출한 객체의 키로 객체에 접근하려고 할 때 에러가 발생하게 됩니다.

type Person = {
  name: string, age: number, id: number,
}
declare const me: Person;

Object.keys(me).forEach(key => {
  // 🚨 Element implicitly has an ‘any’ type because expression of type ‘string’ can’t be used to index type ‘Person’. No index signature with a parameter of type ‘string’ was found on type ‘Person’
  console.log(me[key])
})

 

 

 

🤨 왜 이런 문제가 발생하는 걸까요? 

 

타입스크립트에서는 Object.keys()의 인터페이스를 다음과 같이 지정하고 있습니다.

// typescript/lib/lib.es5.d.ts
interface Object {
  keys(o: object): string[];
}

 

근데 다음과 같이 지정하면 우리가 원하는 타입대로 쓸 수 있는거 아니에요?🤔

interface Object {
  keys<T extends object>(o: T): (keyof T)[];
}

 

타입스크립트에서 Object.keys를 이렇게 정의하지 않은데에는 구조적 타입 시스템과 관련이 있습니다.

 

 

 

🔎 타입스크립트의 구조적 타이핑

 

구조적 타이핑의 가장 기본적인 특성은 값을 할당할 때 정의된 타입에 필요한 속성을 가지고 있다면 호환된다는 점입니다.

따라서 타입 A가 B의 슈퍼셋인 경우(A는 B의 모든 프로퍼티를 포함) 타입 A를 B에 할당할 수 있습니다.

type Person = {
  name: string
};

let person: Person;

const worker = {
  name: 'Seonju',
  job: 'Developer',
};

person = employee; // OK

 

worker는 Person 타입에 필요한 name 속성을 가지고 있기 때문에 그 외의 속성이 있더라도 person의 값으로 할당할 수 있습니다.

따라서 Object.keys의 타입을 좁힐 경우 다음과 같은 문제를 만날 수 있습니다.

 

type AB = {
  a: string;
  b: string;
}

function testFunction(arg: AB) {
  return Object.keys(arg) as (keyof AB)[];
}
const argument = {
  a: 'some',
  b: 'thing',
  c: 'unexpected',
};

const keys = testFunction(argument);

 

argument는 TestType의 서브셋이기 때문에 testFunction에서 타입 에러가 발생하지 않습니다.

실제로는 ('a' | 'b' | 'c')[] 타입이 리턴되어야 하지만 (keyof AB)[] 가 리턴되는 것을 확인할 수 있습니다.

 

정리하면, 객체는 런타임에 더 많은 속성을 가질 수 있기 때문에 Object.keys()의 타입은 (keyof T)[]이 아닌 string[] 타입이 되어야 합니다.

 

따라서 Object.entries 를 사용하여 객체를 순회하거나, 객체에 다른 속성이 포함되지 않을 것을 확신할 수 있는 경우(keyof T)[]로 캐스팅하여 사용해야 함을 인지해야 합니다.

 

 

 

 

💥 Object.keys, Object.entries의 타입을 좁혀주는 유틸 함수 만들기

네~ 하지만 저는 readonly인 객체를 사용할 것이기 때문에 타입 캐스팅을 해줄 것입니다~ 🐒

 

Object.keys()의 리턴 타입을 객체에서 추출한 key 값으로 강제해주는 유틸 함수를 만들었습니다.

type Entries<T> = {
  [K in keyof T]: [K, T[K]];
}[keyof T][];

type TypedObject = Record<string, unknown>;

const getTypedEntries = <T extends TypedObject>(obj: T) => {
  return Object.entries(obj) as Entries<T>;
};

const getTypedKeys = <T extends TypedObject>(obj: T) => {
  const typedKeys = Object.keys(obj) as Array<keyof T>;
  return typedKeys;
};

export const ObjectTyped = {
  entries: getTypedEntries,
  keys: getTypedKeys,
};

 

해당 유틸 함수의 매개변수의 타입을 object로 지정할 경우, objectinterface, class의 상위 타입이기 때문에 모든 타입의 값들을 할당할 수 있게 됩니다.

 

따라서 TypedObject 커스텀 타입을 만들어줘 객체의 타입을 명확하게 지정해주고, Object.keys()의 리턴 타입은 Array<keyof T>로 타입 캐스팅 처리를 했습니다.

 

 

근데… 코드를 유심히 살펴보니 마음에 들지 않는 부분이 있네요🤨

T[] 형태의 타입은 빈 배열도 허용하기 때문이죠!

 

 

❓ zod란?

더보기

zod는 스키마 선언 및 유효성 검증 라이브러리입니다.

zod를 통해 런타임 단계에서의 타입 에러를 잡아줄 수 있습니다.

 

 

GitHub - colinhacks/zod: TypeScript-first schema validation with static type inference

TypeScript-first schema validation with static type inference - colinhacks/zod

github.com

 

zod의 경우에는 enum의 타입은 다음과 같이 정의되어있어요.

 

ZodEnum<T extends [string, ...string[]]>

 

따라서 TypedObject.keys()의 결과값을 z.enum에 전달하면 다음과 같은 에러가 발생해요.

 

zod의 enum에는 빈 배열을 전달할 수 없도록 되어있습니다.

이렇게 되면 우리의 아름다운 TypedObject.keys는 무용지물이 되어버려요.

 

object에 key가 존재하면 빈 배열이 아닌 값이 있는 배열 형태의 타입을 반환하도록 Array의 타입을 좁혀보겠습니다.

 


type TypedArray<T> = T[] extends [...T[]] ? [T, ...T[]] : T[];

const getTypedKeys = <T extends TypedObject>(obj: T) => {
  const typedKeys = Object.keys(obj) as TypedArray<keyof T>;
  return typedKeys;
};

 

이제 Object.keys()를 통해 반환된 키 배열은 해당 객체의 키 집합에 대한 안전한 타입을 보장받을 수 있게 되었습니다. 😁