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;
동작 원리를 좀 더 구체적으로 작성해보면, 다음과 같다 ✈️
- Intersection 타입
{ description: string } & T는 기본 설명과 제네릭 타입 T의 속성을 모두 결합하여 새로운 타입을 만든다.
예를 들어, T가 { price: number }라면 결과는 { description: string, price: number }가 된다. - 조건부 타입
결합된 타입이 { 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)
기본 타입에 사용자 정의 타입을 덮어써서 보다 구체적인 타입을 생성할 수 있다
요렇게 다양한 객체들의 타입을 잘 비교하고 확장, 조건부로 검사해 사용하는 예제들을 확인해봤다.
하지만 실제 실무에서는 또 예기치못한 타입들을 만날 수 있기에... 늘 다양한 방법과 기본 지식에 대해서도 잊지 않도록 반복, 익숙해지도록 하는 게 중요한 것 같다 💙
'TypeScript' 카테고리의 다른 글
TypeScript에서 __brand 패턴 알아보기 ✈️ (타입 안정성 높이기) (0) | 2025.03.30 |
---|---|
[typescript] Record를 사용하여 객체 Key 타입 설정하기 🍎 (0) | 2024.11.01 |
타입스크립트에서 기존 코드로부터 타입을 가져오는 법 ReturnType, ComponentProps (0) | 2024.09.18 |
타입가드 사용하여 안전하게 타입스크립트 타이핑하기 (0) | 2024.08.04 |
TypeScript에서 enum 사용하기: const enum vs as const 객체 (0) | 2024.04.01 |