TypeScript에서 객체 타입 비교 및 extends 활용하기 🏘️

TypeScript는 구조적 타이핑(structural typing) 을 기반으로 동작한다.

 

즉, 객체의 타입을 비교할 때 객체가 가진 속성들을 기준으로 판단하게 된다 🫰

이를 활용하면 extends 키워드를 통해 타입 간의 관계(할당 가능 여부)를 쉽게 판별할 수 있다.

 

이번에는 타입스크립트에서 객체를 비교하는 다양한 예제를 테스트해보는 글을 남겨보기로 했다.

(extends와 함께하는... ⭐)


구조적 타이핑과 객체 타입 비교

 

먼저, 간단한 두 객체 타입을 예제로 들어보자.

 

아래와 같은 예제, 기본 데이터 구조를 나타내는 BasicRecord와 추가 정보를 담은 DetailedRecord로 구조적 타이핑에 대해서 살펴보자.

type BasicRecord = { values: string[] };
type DetailedRecord = { values: string[]; count: number };

// DetailedRecord는 BasicRecord가 요구하는 속성(values)을 모두 포함하므로
type TestBasic = DetailedRecord extends BasicRecord ? true : false;  // 결과: true

// 반대로, BasicRecord는 DetailedRecord의 추가 속성(count)을 포함하지 않으므로
type TestDetailed = BasicRecord extends DetailedRecord ? true : false; // 결과: false

 

이처럼 extends 키워드는 두 타입 간의 할당 가능성(assignability) 을 판단하는 데 사용할 수 있다.

(비교적 작은 타입이 넓은 타입에 포함될 수 있다)

 

💫

제네릭과 extends를 활용한 타입 제약

제네릭과 extends를 함께 사용하면, 함수나 타입이 특정 형태의 객체만 다루도록 제한할 수 있다.

 

아래 예제는 processRecord 함수가 전달받은 제네릭 타입 T가 반드시 BasicRecord의 구조, 즉 values: string[]를 포함하도록 강제한다.

function processRecord<T extends { values: string[] }>(record: T): void {
  console.log(record.values);
}

// 사용 예제
processRecord({ values: ["alpha", "beta"] });          // 정상 작동
processRecord({ values: ["alpha"], count: 10 });         // 정상 작동 (추가 속성은 허용됨)
processRecord({ count: 10 });                        // 오류: 필수 속성 'values'가 없음

 

위와 같이 제네릭 제약은 함수나 타입의 사용 범위를 좀 더 명확하게 지정하여 코드의 안정성을 높여줄 수 있다.

 

💫

선택적(optional)과 필수(required) 속성 비교

객체 타입 비교에서 중요한 또 다른 요소는 속성이 선택적(optional) 인지 필수(required) 에 대한 비교이다.

 

아래 예제를 통해 그 차이를 살펴보자

type RequiredField = { value: string };
type OptionalField = { value?: string };

type TestRequired = RequiredField extends OptionalField ? true : false; // 결과: true
type TestOptional = OptionalField extends RequiredField ? true : false; // 결과: false
  • RequiredField는 value가 반드시 존재해야 한다.
  • OptionalField는 value가 없어도 허용되므로, 필수 조건을 가진 RequiredField는 항상 OptionalField의 조건을 만족하지만 반대는 성립하지 않는다.

💫

 

조건부 타입을 활용한 부분집합 판별

조건부 타입을 활용하면 두 타입 간의 “부분집합” 관계를 손쉽게 표현할 수 있다.

(사실, 위의 내용들도 잘 생각해보면 집합의 개념과 다를게 없었다... 🌠)

 

아래 IsSubset 유틸리티 타입은 타입 T가 타입 U의 조건을 모두 만족하는지를 판별한다.

// T가 U의 부분집합이면 true, 아니면 false
type IsSubset<T, U> = T extends U ? true : false;

/* 예제 */

// DetailedRecord는 BasicRecord를 확장하므로 true
type SubsetTest1 = IsSubset<DetailedRecord, BasicRecord>;

// BasicRecord는 DetailedRecord의 조건을 모두 만족하지 않으므로 false
type SubsetTest2 = IsSubset<BasicRecord, DetailedRecord>;

 

💫

Intersection 타입과 extends를 활용한 객체 비교

Intersection 타입을 사용해 두 타입을 결합한 후 특정 조건을 만족하는지를 체크하는지도 확인할 수 있다.

 

아래 예제에서는 기본 설명(description)을 가진 타입과 전달받은 제네릭 타입 T를 결합한 후, 이 결과에 price: number 속성이 반드시 포함되는지 체크한다.

type CompareWithIntersection<T> = ({ description: string } & T) extends { price: number } ? true : false;

 

동작 원리를 좀 더 구체적으로 작성해보면, 다음과 같다 ✈️

  1. Intersection 타입
    { description: string } & T는 기본 설명과 제네릭 타입 T의 속성을 모두 결합하여 새로운 타입을 만든다.
    예를 들어, T가 { price: number }라면 결과는 { description: string, price: number }가 된다.
  2. 조건부 타입
    결합된 타입이 { price: number }를 확장하는지 체크한다.
    • 조건을 만족하면 true를 반환하고,
    • 그렇지 않으면 false를 반환한다.

좀 더 구체적인 예제는 다음과 같다.

// 예제 1: T에 { price: number }가 포함된 경우
type ExampleIntersection1 = CompareWithIntersection<{ price: number }>;
// 결과: true

// 예제 2: T에 price의 타입이 다른 경우
type ExampleIntersection2 = CompareWithIntersection<{ price: string }>;
// 결과: false

// 예제 3: T에 price 속성이 없는 경우
type ExampleIntersection3 = CompareWithIntersection<{ available: boolean }>;
// 결과: false

// 예제 4: T에 price가 선택적(optional)로 정의된 경우
type ExampleIntersection4 = CompareWithIntersection<{ price?: number }>;
// 결과: false

 

 

💫

좀 더 알아보기 ~ 중첩(Deep) 객체 타입 비교

실무에서는 객체가 중첩되어 있는 경우가 많다.

이때, 단순한 할당 검사로는 충분하지 않을 수가 있는데 이 경우 재귀적 조건부 타입을 활용해 중첩 객체까지 비교할 수 있다.

 

아래 DeepExtends 타입은 타입 T가 타입 U의 모든 속성을(중첩된 경우에도) 만족하는지를 재귀적으로 검사한다.

 

type DeepExtends<T, U> =
  T extends U
    ? U extends object
        ? { [K in keyof U]: K extends keyof T ? DeepExtends<T[K], U[K]> : false }[keyof U] extends false
          ? false
          : true
        : true
    : false;
  • 먼저, 최상위 레벨에서 T extends U를 검사한다.
  • 만약 U가 객체라면 각 속성 K에 대해 T[K]와 U[K]를 재귀적으로 비교한다.
  • 한 속성이라도 조건에 맞지 않으면 전체 결과는 false가 된다.

사용 예제 ☘️

type FullConfig = {
  server: {
    host: string;
    port: number;
  };
  featureFlags: {
    darkMode: boolean;
  };
};

type PartialConfig = {
  server: {
    host: string;
  };
};

type DeepTest1 = DeepExtends<FullConfig, PartialConfig>; // 결과: true

type MismatchedConfig = {
  server: {
    host: number; // 타입 불일치
  };
};

type DeepTest2 = DeepExtends<FullConfig, MismatchedConfig>; // 결과: false

 

위와 같이 DeepExtends를 사용하면 중첩 객체의 각 속성까지도 재귀적으로 비교할 수 있다!

 

💫

좀 더 더 알아보기 ~ 타입 병합(Merge) 및 오버라이딩

실제 프로젝트에서는 기본 설정과 사용자 정의 설정을 병합하거나, 일부 속성을 오버라이딩하는 경우도 드물게 있다.

 

Merge 유틸리티 타입은 두 객체 타입을 병합하면서, 두 번째 타입의 속성이 첫 번째를 덮어쓰도록 해준다.

 

type Merge<T, U> = Omit<T, keyof U> & U;

 

사용 예제 👻

type DefaultConfig = {
  url: string;
  timeout: number;
  retries?: number;
};

type CustomConfig = {
  timeout: number;
  retries: number;
};

type MergedConfig = Merge<DefaultConfig, CustomConfig>;
// 결과: { url: string; timeout: number; retries: number; }

type MergeTest = MergedConfig extends DefaultConfig ? true : false; // 결과: true

 

위 예제에서 MergedConfig는 기본 설정(DefaultConfig)에 사용자 정의 설정(CustomConfig)을 덮어쓴 결과로, 추가된 필수 속성이 기존 타입의 선택적 속성보다 더 구체적인 타입을 제공하게 된다.

 

💫

 

이번 글에서는 TypeScript의 강력한 타입 시스템을 활용하여 객체 타입 비교와 고급 타입 조작을 수행하는 다양한 기법들을 알아보았다.

  • 구조적 타이핑
        객체의 속성을 기준으로 타입 간의 관계를 판단한다
  • 제네릭 제약
        T extends SomeType을 사용하여 특정 구조를 가진 객체만 다루도록 제한할 수 있다
  • 조건부 타입
        T extends U ? true : false를 활용해 타입 간의 부분집합 여부를 쉽게 판별할 수 있다
  • 선택적 vs 필수 속성
        필수 속성을 가진 타입은 선택적 속성을 포함하는 타입으로 확장이 가능하지만, 그 반대는 성립하지 않다
  • Intersection 타입
        여러 타입을 결합하여 특정 조건을 검사하는 데 유용하다
  • 재귀적 조건부 타입
        중첩 객체의 각 속성을 세밀하게 비교할 때 활용할 수 있다
  • 타입 병합(Merge)
        기본 타입에 사용자 정의 타입을 덮어써서 보다 구체적인 타입을 생성할 수 있다

 

요렇게 다양한 객체들의 타입을 잘 비교하고 확장, 조건부로 검사해 사용하는 예제들을 확인해봤다.

하지만 실제 실무에서는 또 예기치못한 타입들을 만날 수 있기에... 늘 다양한 방법과 기본 지식에 대해서도 잊지 않도록 반복, 익숙해지도록 하는 게 중요한 것 같다 💙