개발새발

객체 간에 대입할 수 있는지 확인하는 법을 배우자 본문

Typescript

객체 간에 대입할 수 있는지 확인하는 법을 배우자

비숑주인 2025. 3. 22. 17:49

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의 타입 호환성은 처음엔 헷갈릴 수 있지만, 넓은 타입 → 좁은 타입으로의 대입만 허용된다는 원칙만 기억해두면 어렵지 않다.