구조적 타이핑(Structural Typing)이란 타입의 이름이 아니라 구조(shape) 로 호환성을 판단하는 방식입니다. TypeScript의 타입 시스템의 근간입니다.

구조적 타이핑이란

TYPESCRIPT
interface Point {
  x: number;
  y: number;
}

// 이름이 다르지만 구조가 같으면 호환됨
interface Coordinate {
  x: number;
  y: number;
}

const coord: Coordinate = { x: 1, y: 2 };
const point: Point = coord; // ✅ OK — 구조가 같으므로

// 인터페이스를 구현하지 않은 일반 객체도 가능
function printPoint(p: Point) {
  console.log(`(${p.x}, ${p.y})`);
}

printPoint({ x: 10, y: 20 }); // OK — 구조만 맞으면 됨

Java나 C#은 명목적 타이핑(Nominal Typing) 을 사용합니다. 타입 이름이 다르면 구조가 같아도 호환되지 않습니다. TypeScript는 JavaScript의 덕 타이핑(Duck Typing) 철학을 타입 시스템에 반영한 것입니다.

"오리처럼 걷고, 오리처럼 꽥꽥대면 — 오리다."

초과 속성은 허용된다

TYPESCRIPT
interface User {
  name: string;
  age: number;
}

// 추가 속성이 있어도 필수 속성만 있으면 호환
const fullUser = { name: '홍길동', age: 25, email: 'test@test.com' };

const user: User = fullUser; // ✅ OK — email은 무시됨

// ⚠️ 단, 객체 리터럴을 직접 할당하면 초과 속성 검사가 동작
// const user2: User = { name: '홍길동', age: 25, email: 'test' }; // ❌ Error

공부하다 보니 이 차이가 꽤 헷갈리더라고요. 변수를 거치면 초과 속성이 허용되지만, 리터럴 직접 할당에서는 에러가 납니다. 이는 TypeScript가 "오타 방지"를 위해 리터럴에만 추가 검사를 하기 때문입니다.

함수의 타입 호환성

함수의 호환성은 매개변수와 반환값 양쪽을 모두 검사합니다.

매개변수 — 적은 것은 OK

TYPESCRIPT
type Handler = (a: number, b: string) => void;

// 매개변수가 적은 함수는 호환됨
const handler1: Handler = (a) => console.log(a); // ✅ OK
const handler2: Handler = () => {};               // ✅ OK

// 매개변수가 많은 함수는 호환 안 됨
// const handler3: Handler = (a, b, c) => {};     // ❌ Error

이것은 JavaScript에서 콜백 매개변수를 무시하는 것이 일반적이기 때문입니다.

TYPESCRIPT
// 흔한 패턴: forEach 콜백에서 index를 안 쓰는 것
[1, 2, 3].forEach((value) => console.log(value));
// forEach는 (value, index, array) => void를 기대하지만 value만 받아도 OK

반환값 — 더 많은 것은 OK

TYPESCRIPT
type GetUser = () => { name: string };

// 반환값에 추가 속성이 있어도 OK
const getUser: GetUser = () => ({ name: '홍길동', age: 25 }); // ✅

클래스의 구조적 타이핑

TYPESCRIPT
class Animal {
  constructor(public name: string) {}
}

class Person {
  constructor(public name: string) {}
}

// 같은 구조이므로 호환됨
let animal: Animal = new Person('홍길동'); // ✅ OK
let person: Person = new Animal('강아지'); // ✅ OK

private/protected가 있으면 다르다

TYPESCRIPT
class Dog {
  private breed: string;
  constructor(public name: string, breed: string) {
    this.breed = breed;
  }
}

class Cat {
  private breed: string;
  constructor(public name: string, breed: string) {
    this.breed = breed;
  }
}

// ❌ private 멤버가 다른 클래스에서 왔으므로 호환 안 됨
// const dog: Dog = new Cat('나비', '페르시안'); // Error

private이나 protected 멤버는 같은 클래스 계층 에서 왔을 때만 호환됩니다.

제네릭의 타입 호환성

TYPESCRIPT
interface Empty<T> {}

// T가 사용되지 않으면 구조가 동일하므로 호환
const a: Empty<number> = {};
const b: Empty<string> = a; // ✅ OK

interface NotEmpty<T> {
  value: T;
}

// T가 사용되면 구조가 달라지므로 호환 안 됨
const c: NotEmpty<number> = { value: 42 };
// const d: NotEmpty<string> = c; // ❌ Error

실전 영향

의도치 않은 호환

TYPESCRIPT
// UserId와 ProductId가 둘 다 number이므로 호환됨
type UserId = number;
type ProductId = number;

function getUser(id: UserId) { /* ... */ }

const productId: ProductId = 42;
getUser(productId); // ⚠️ 타입 에러가 안 남! — 논리적으로는 잘못된 호출

이 문제를 해결하려면 Branded Types 를 사용합니다 (심화 편에서 다룸).

TYPESCRIPT
// 미리보기: Branded Types로 해결
type UserId = number & { __brand: 'UserId' };
type ProductId = number & { __brand: 'ProductId' };

function getUser(id: UserId) { /* ... */ }

const productId = 42 as ProductId;
// getUser(productId); // ❌ Error — 브랜드가 다름

정리

  • TypeScript는 구조적 타이핑으로, 이름이 아니라 구조로 호환성을 판단한다
  • 초과 속성은 변수 할당 시 허용되지만, 객체 리터럴에서는 검사된다
  • 함수는 매개변수가 적은 것은 호환되고, 반환값이 많은 것은 호환된다
  • private/protected가 있으면 같은 클래스 계층에서만 호환된다
  • 구조적 타이핑의 한계는 Branded Types로 보완할 수 있다
댓글 로딩 중...