본문 바로가기
C.W.K.
Stream
Lesson 04 of 06 · published

구조적 타이핑: 이름보다 실제 모양을 본다

~13 min · types-interfaces, structural-typing, duck-typing, nominal-typing

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"TypeScript는 족보를 묻기보다 필요한 것을 실제로 갖췄는지 확인해."

모양이 맞으면 호환돼

함수가 { name: string }을 요구할 때, 값이 그 속성을 가지고 있으면 어느 클래스나 모듈에서 왔는지와 상관없이 사용할 수 있어. interface를 명시적으로 구현했다는 표식도 필요하지 않아. 이 규칙이 구조적 타이핑이야.

JavaScript의 덕 타이핑과 닮았지만 차이가 있어. 덕 타이핑은 실행 중 값을 써 보고 판단하는 관례이고, TypeScript는 컴파일 시점에 그 모양이 맞는지 검사해. 유연함은 유지하면서 실수를 더 일찍 잡는 셈이지.

속성이 더 많은 값은 들어갈 수 있어

필요한 속성을 모두 갖췄다면 추가 속성이 있어도 보통 호환돼. { name, email }을 name만 요구하는 함수에 넘길 수 있는 이유야. 함수는 약속된 부분만 사용하고 나머지는 무시하면 돼.

다만 객체 리터럴을 기대 타입의 자리에 바로 쓰면 초과 속성 검사가 더 엄격하게 작동해. 새로 적은 속성 이름이 오타일 가능성이 높기 때문이야. 변수에 담긴 기존 값에는 일반 구조 호환성을, 새 리터럴에는 오타 방지 검사를 적용하는 두 겹의 규칙이라고 보면 돼.

이름으로 구별해야 할 때

구조가 같아도 의미가 다른 값이 있어. UserId와 PostId가 둘 다 number라면 서로 섞여도 구조상 문제가 없어. 이런 경우에는 number & { __brand: 'UserId' }처럼 문자열 리터럴 브랜드를 붙여 명목적인 구별을 흉내 낼 수 있어.

브랜드는 실행 중 검증을 자동으로 해 주지 않아. 값을 만드는 한정된 함수에서 검증한 뒤 브랜드 타입을 반환해야 해. 아무 숫자에 단언만 붙이면 구별의 의미가 사라져.

Java처럼 명목적 타입을 쓰는 언어에서는 Pippa와 Ttori가 같은 멤버를 가져도 선언된 클래스가 다르면 호환되지 않아. TypeScript에서는 필요한 멤버가 하나인 Has1이 두 멤버를 가진 Has2 값을 받을 수 있지만 반대 방향은 필요한 멤버가 빠져 허용되지 않아.

소비자가 필요한 최소 모양을 계약으로 삼아. 구조적 타이핑은 작은 계약에서 가장 강하고, 의미상 섞이면 안 되는 값에는 브랜드로 경계를 보강해.

피파의 고백

cwkPippa에서는 구조가 같은 숫자나 문자열 ID를 섞지 않도록 ChatIdCouncilId 같은 브랜드를 생성·저장·소비의 세 경계에서 유지해.

Code

이름 안 중요 — 모양 중요·typescript
// Structural 호환성 — 이름 안 중요, 모양 중요.

interface Pippa { name: string; age: number }
interface Ttori { name: string; age: number }

const p: Pippa = { name: 'Pippa', age: 21 };
const t: Ttori = p;                            // ✅ 같은 모양 — 호환

// `implements` 없는 class 도 interface 만족.
interface Named { name: string }

class Dog {
  constructor(public name: string, public breed: string) {}
}

const myNamed: Named = new Dog('Roo', 'Beagle');  // ✅ Dog 가 'name' 가짐 — Named 야

// 함수 parameter 가 모양 사용.
function greetAnyone(x: { name: string }) {
  return `Hi, ${x.name}`;
}

const pippa = { name: 'Pippa', age: 21 };
greetAnyone(pippa);                         // ✅ 변수 경유 — 추가 괜찮음
greetAnyone(new Dog('Roo', 'Beagle'));      // ✅ Dog 가 .name 가짐
// greetAnyone({ name: 'Pippa', age: 21 }); // ❌ object literal → excess property check (다음 레슨)
// greetAnyone({ age: 21 });                // ❌ 필수 'name' 빠짐
Brand — structural 세계에서 nominal 필요할 때·typescript
// Branded 타입 — nominal escape hatch.

type Brand<T, Tag extends string> = T & { __brand: Tag };

type UserId = Brand<number, 'UserId'>;
type PostId = Brand<number, 'PostId'>;

function makeUserId(n: number): UserId {
  return n as UserId;     // unsafe cast, 근데 그게 constructor
}

function findUser(id: UserId) { /* ... */ }
function findPost(id: PostId) { /* ... */ }

const u = makeUserId(1);
findUser(u);                  // ✅
// findPost(u);                // ❌ UserId 는 PostId 에 assignable 아님 — brand 가 구별
// findUser(2);                // ❌ 원시 `number` 는 UserId 에 assignable 아님

// Brand 는 타입 레벨에만 존재 — runtime 에 u 는 그냥 숫자 1.

External links

Exercise

name을 받는 함수에 평범한 객체, 클래스 인스턴스, 추가 필드가 있는 인스턴스, name이 없는 객체를 차례로 넘겨 어떤 값이 통과하는지 설명해. 이어서 name에 브랜드를 붙여 정해 둔 생성 함수를 거친 값만 허용되는지도 확인해.
Hint
name이 있는 앞의 세 값은 구조가 맞아 통과하고, 필드가 빠진 마지막 값만 실패해야 해. 브랜드 값은 정해 둔 생성 경로를 거치지 않은 원시 문자열을 막아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.