어제 오늘 내일

[TypeScript] 9. 구조적 타이핑 — TypeScript가 타입을 비교하는 방식 본문

IT/TypeScript

[TypeScript] 9. 구조적 타이핑 — TypeScript가 타입을 비교하는 방식

hi.anna 2026. 8. 10. 18:52

이 글의 목표: "왜 이 타입이 저 타입에 할당되지?" 같은 혼란을 없앱니다. TypeScript 타입 호환성의 근본 원리를 이해합니다.

이름이 아니라 "모양"으로 판단한다

Java나 C#은 타입 이름이 같아야 호환됩니다(명목적 타이핑, nominal typing). TypeScript는 다릅니다. 모양(구조)이 같으면 호환됩니다. 이것이 구조적 타이핑(structural typing)입니다.

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

// Point라고 선언하지 않았지만, x와 y를 가진 객체
const coord = { x: 10, y: 20 };

// 모양이 Point와 같으므로 할당된다 — 이름은 상관없다
const p: Point = coord; // ✅

이걸 "덕 타이핑(duck typing)"이라고도 부릅니다. "오리처럼 걷고 오리처럼 울면 오리다."

더 많은 속성은 괜찮다

필요한 속성을 모두 가지고 있으면, 추가 속성이 있어도 할당됩니다.

interface Named {
  name: string;
}

const person = { name: "철수", age: 30 };

// person은 name을 가지므로 Named로 취급 가능 (age가 남아도 OK)
const n: Named = person; // ✅

이 원리는 함수에도 적용됩니다.

function printName(obj: { name: string }) {
  console.log(obj.name);
}

// name을 포함하면 다른 속성이 있어도 넘길 수 있다
printName({ name: "영희", age: 25 }); // ✅

예외: 객체 리터럴의 "초과 속성 검사"

방금 규칙에 예외가 하나 있습니다. 객체 리터럴을 직접 할당할 때는, 정의에 없는 속성이 있으면 오히려 에러가 납니다. 오타를 잡기 위한 안전장치입니다.

interface Config {
  url: string;
}

// 변수를 거치면 OK (구조적 타이핑)
const raw = { url: "/api", timeout: 5000 };
const c1: Config = raw; // ✅

// 리터럴을 직접 주면 초과 속성 검사가 작동한다
const c2: Config = { url: "/api", timeout: 5000 };
// ❌ 'timeout'은 Config에 없습니다. 오타입니까?

왜 이렇게 다를까요? 리터럴을 바로 쓸 때 나타나는 초과 속성은 "실수(오타)"일 가능성이 높기 때문입니다. TypeScript는 이 흔한 실수를 잡아줍니다.

interface Options {
  color?: string;
}

// color를 colour로 오타 → 초과 속성 검사가 즉시 잡아준다
const opt: Options = { colour: "red" };
// ❌ 'colour'는 Options에 없습니다. 'color'를 쓰려던 건가요?

함수 타입의 호환성

함수도 구조적으로 비교됩니다. 여기엔 살짝 직관에 반하는 규칙이 있습니다.

매개변수가 더 적은 함수는 호환된다

type Callback = (value: number, index: number) => void;

// index를 안 받는 함수도 Callback으로 쓸 수 있다
const cb: Callback = (value) => {
  console.log(value); // index를 무시해도 문제없다
};

Array.forEach에 콜백을 넘길 때 인덱스를 생략할 수 있는 이유가 바로 이것입니다. 더 적은 인자를 받는 함수는 항상 안전하게 호출 가능합니다.

반환 타입: 더 구체적인 것은 호환된다

type GetValue = () => { name: string };

// 더 많은 속성을 반환해도 OK (name을 포함하므로)
const fn: GetValue = () => ({ name: "철수", age: 30 }); // ✅

브랜드 타입: 구조적 타이핑을 우회하기

가끔은 구조가 같아도 구분하고 싶을 때가 있습니다. 예를 들어 UserIdPostId는 둘 다 string이지만 섞이면 안 됩니다.

// 브랜드(가상의 표식)를 붙여 명목적 타이핑을 흉내낸다
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };

// 브랜드를 부여하는 함수
function toUserId(id: string): UserId {
  return id as UserId;
}

const uid = toUserId("u1");

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

getUser(uid);      // ✅
// getUser("u1");   // ❌ 그냥 string은 UserId가 아니다
// getUser(postId); // ❌ PostId를 넣으면 막힌다

이 기법은 ID 뒤바뀜 같은 미묘한 버그를 컴파일 타임에 막아줍니다.

정리

  • TypeScript는 이름이 아니라 모양으로 타입 호환을 판단한다(구조적 타이핑).
  • 필요한 속성을 다 가지면, 추가 속성이 있어도 호환된다.
  • 단, 객체 리터럴을 직접 할당할 때는 초과 속성 검사가 작동해 오타를 잡는다.
  • 함수는 "더 적은 매개변수", "더 구체적인 반환값"이면 호환된다.
  • 구조가 같아도 구분하고 싶다면 브랜드 타입을 쓴다.

여기까지가 Part 2입니다. 다음 글부터는 TypeScript의 진짜 강력한 무기, 제네릭으로 들어갑니다.

반응형
Comments