개발새발
객체 간에 대입할 수 있는지 확인하는 법을 배우자 본문
TypeScript를 사용하다 보면 자주 마주하는 질문이 있다.
“이 객체는 이 타입에 대입할 수 있을까?”
그에 대한 해답은 타입 호환성(type compatibility) 이라는 원칙 안에 있다. 이 글에서는 객체 간의 타입 대입 가능 여부를 구조적 타이핑과 넓은/좁은 타입 개념을 통해 설명하겠다.
기본 예시 – 속성이 포함되면 대입 가능
interface A {
name: string;
}
interface B {
name: string;
age: number;
}
const aObj = { name: 'zero' };
const bObj = { name: 'nero', age: 32 };
const aToA: A = aObj; // ✅ 가능
const bToA: A = bObj; // ✅ 가능 (속성 더 많아도 됨)
const bToB: B = bObj; // ✅ 가능
const aToB: B = aObj; // ❌ Error: 'age' 속성이 없음
A 타입에 B 타입 객체를 대입하는 것은 가능하지만, B 타입에 A 타입 객체를 대입하는 것은 불가능하다.
좁은 타입은 넓은 타입에 대입할 수 있지만, 넓은 타입은 좁은 타입에 대입할 수 없다는 규칙은 객체에도 똑같이 적용된다.
B 타입에는 name과 age 속성이 꼭 있어야 하지만, A 타입에는 name밖에 없으니, age 속성이 없을 수 있으므로 B 타입에서 A 타입으로는 대입할 수 없다. B가 코드의 양과 줄 수가 더 많은 이유는 그 만큼 더 구체적으로 적었기 때문이다.
핵심 규칙
- 속성이 더 많은 객체는 더 적은 객체에 대입할 수 있다.
- 속성이 부족한 객체는 더 많은 속성을 요구하는 타입에 대입할 수 없다.
즉, B는 A에 대입 가능하지만 A는 B에 대입 불가능하다.
넓은 타입 vs 좁은 타입
이해하기 쉽게 집합 이론으로 생각해보면:
- A = { name: string }는 더 넓은 타입
- B = { name: string, age: number }는 더 좁은 타입
더 좁은 타입은 더 많은 조건(속성)을 만족해야 하므로 대입 가능한 방향은:
좁은 타입 → 넓은 타입
유니언과 인터섹션이 헷갈린다면?
type A = { name: string };
type B = { age: number };
type Union = A | B;
type Intersection = A & B;
유니언
function test(): A | B {
return Math.random() > 0.5 ? { name: 'zero' } : { age: 30 };
}
const u: A = test(); // ❌ 에러
const v: B = test(); // ❌ 에러
const w: A & B = test(); // ❌ 에러
- A | B는 A 또는 B일 수 있어서 어느 속성이 존재할지 보장할 수 없다.
- 따라서 어느 하나라도 속성이 누락될 가능성이 있으면 대입할 수 없다.
배열과 튜플 간 대입
let a: ['hi', 'readonly'] = ['hi', 'readonly'];
let b: string[] = ['hi', 'normal'];
a = b; // ❌ 배열은 튜플보다 넓음
b = a; // ✅ 튜플은 배열보다 좁음
✅ 튜플 → 배열은 가능
❌ 배열 → 튜플은 불가능 (요소 개수, 순서가 보장되지 않음)
readonly 배열과 일반 배열
let ro: readonly string[] = ['a', 'b'];
let normal: string[] = ['a', 'b'];
ro = normal; // ✅ 가능
normal = ro; // ❌ Error: readonly 배열을 변경 가능한 배열에 대입할 수 없음
- readonly 배열은 더 넓은 타입이다.
- 변경 가능한 배열은 더 구체적이므로 더 좁은 타입이다.
optional 속성은 더 넓은 타입
type Optional = { a?: string; b?: string };
type Mandatory = { a: string; b: string };
const opt: Optional = { a: 'hello' };
const mand: Mandatory = { a: 'hello', b: 'world' };
const o2: Optional = mand; // ✅ 가능
const m2: Mandatory = opt; // ❌ Error: optional → mandatory 불가
옵셔널(?) 속성은 해당 속성에 undefined가 추가된 유니언 타입이므로 더 넓은 타입이다.
readonly 속성은 객체에서는 호환됨
type ReadOnly = {
readonly a: string;
readonly b: string;
};
type Mutable = {
a: string;
b: string;
};
const ro: ReadOnly = { a: 'hi', b: 'world' };
const mu: Mutable = { a: 'hello', b: 'world' };
const ro2: ReadOnly = mu; // ✅ 가능
const mu2: Mutable = ro; // ✅ 가능
객체에서는 readonly 여부가 타입 호환성에 영향을 주지 않는다. 단, 배열에서는 다르다!
구조적 타이핑 (Structural Typing)
interface Money {
amount: number;
unit: string;
}
interface Liter {
amount: number;
unit: string;
}
const liter: Liter = { amount: 1, unit: 'liter' };
const currency: Money = liter; // ✅ 가능
- 타입 이름은 달라도 구조가 같으면 대입 가능
- TypeScript는 명목적(nominal) 이 아닌 구조적(structural) 타입 시스템을 따른다.
구조적 타입의 강제 구분 – 브랜딩(Branding)
interface Money {
_type: 'money';
amount: number;
unit: string;
}
interface Liter {
_type: 'liter';
amount: number;
unit: string;
}
const liter: Liter = { _type: 'liter', amount: 1, unit: 'L' };
const currency: Money = liter; // ❌ Error: _type 다름
- 구조적으로 같아도 _type 같은 고유 속성을 추가하면 서로 대입되지 않게 강제할 수 있다.
- 이를 브랜딩이라 하며, 타입 안전성을 강화할 때 유용하다.
조건 | 대입 가능 여부 |
더 많은 속성을 가진 객체 → 적은 속성 타입 | ✅ 가능 |
옵셔널 속성 → 필수 속성 | ❌ 불가능 |
배열 → 튜플 | ❌ 불가능 |
튜플 → 배열 | ✅ 가능 |
readonly 배열 → 일반 배열 | ❌ 불가능 |
일반 배열 → readonly 배열 | ✅ 가능 |
객체 readonly → mutable | ✅ 가능 |
구조만 동일한 객체 | ✅ 가능 (구조적 타이핑) |
구조 구분을 위한 고유 속성 추가 | ❌ 대입 불가 (브랜딩 효과) |
TypeScript의 타입 호환성은 처음엔 헷갈릴 수 있지만, 넓은 타입 → 좁은 타입으로의 대입만 허용된다는 원칙만 기억해두면 어렵지 않다.
'Typescript' 카테고리의 다른 글
함수와 메서드를 타이핑하자 (0) | 2025.03.29 |
---|---|
조건문과 비슷한 컨디셔널 타입이 있다 (0) | 2025.03.29 |
타입도 상속이 가능하다 (0) | 2025.03.22 |
타입을 집합으로 생각하자 (유니언, 인터섹션) (1) | 2025.03.22 |
객체의 속성과 메서드에 적용되는 특징을 알자 (0) | 2025.03.22 |