C.W.K.
Stream
Lesson 04 of 06 · published

Structural Typing: Compile 시점 Duck Typing

~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 말함, 그래 오리야."

TypeScript 의 가장 구별되는 단일 특징

대부분 정적 type system 은 nominal: 모양 같지만 선언된 이름 다른 두 타입은 호환 안 됨. Java 의 `class Pippa` 와 `class Ttori` 가 필드 같아도 하나가 다른 거 extend 안 하면 서로 assign 못 함. Compiler 가 가족 tree 강제.

TypeScript 는 structural: 두 타입이 모양 일치하면 호환, 이름이나 선언된 lineage 관계없이. type Pippa = { name: string }type Ttori = { name: string } 가 호환 — `Ttori` 기대하는 곳에 `Pippa` 전달 가능. Compiler 는 모양만 신경, label 아냐.

왜 이게 중요

Structural typing 이 TypeScript 가 JavaScript 위에 가볍게 유지되는 법. JavaScript 는 항상 runtime 에 duck-typed — `if (obj.quack) obj.quack()` 가 `obj` 가 어느 class 에서 왔는지 신경 안 써. TypeScript 가 그 duck-typing 을 compile 시점에 옮기되 모든 object 가 lineage 선언하도록 강제하지 않았어. 결과는 JavaScript-자연스럽게 느껴지고 관료주의 없이 버그 잡아.

Nominal system 이 고생하는 패턴도 가능하게 해. `name` 과 `email` 필드 가진 어떤 object 든 받고 싶어? Interface 필요 없어 — (u: { name: string; email: string }) 말하면 돼. `.length` 가진 어떤 거든 작동하는 함수? (x: { length: number }). 모양이 타입.

호환성 규칙

A 타입의 값이 B 타입에 assignable 한 조건: AB 의 모든 멤버 (호환 가능한 타입으로) 최소한 가질 때. 추가는 괜찮음 — 부족은 안 됨.

type Has1 = { a: number };
type Has2 = { a: number; b: string };
const v2: Has2 = { a: 1, b: 'x' };
const v1: Has1 = v2;  // ✅ Has2 가 Has1 이 필요한 거 다 가짐 (그리고 더)

그래서 class 가 interface 만족하는 데 implements 필요 없어. 모양 일치하면 compiler 가 받아들임.

Structural 호환성 흐름: 추가는 허용, 빠진 property 는 아님. Has2 = Has1 + 추가 면, Has2 → Has1 assignment 괜찮음; Has1 → Has2 는 아님. 넓은 타입이 좁은 거 받아, 반대로는 절대 안 됨.

"추가는 괜찮음" 에 한 가지 단서: 값이 변수 를 거쳐 들어올 때 성립해. 함수 호출이나 할당에 object literal 을 직접 넘기면 TypeScript 가 더 엄격한 규칙 — excess property checking — 을 얹어서 선언 안 된 property 를 오타 잡듯 거부해. greetAnyone({ name: 'Pippa', age: 21 }) 는 에러, 근데 변수 거친 greetAnyone(pippa) 는 통과. 그게 바로 다음 레슨.

Branded 타입 패턴 (nominal 의 escape hatch)

가끔 nominal 필요해 — 둘 다 number 인데도 UserIdPostId 가 호환 안 되길 원해. 커뮤니티 패턴은 brand: type UserId = number & { __brand: 'UserId' }. 고유 marker 와의 intersection 이 타입을 구조적으로 구별되게 만들어. Runtime 비용 없음 — brand 는 type layer 전용.

피파의 고백

cwkPippa frontend 가 어디서나 structural typing 써. BrainName 은 그냥 string literal union, class 아냐. Conversation 모양이 component 사이를 구조로 흘러. Escape hatch — branded 타입 — 은 정확히 3 자리에 나타나, `ChatId` 와 `CouncilId` 가 둘 다 string 인데도 호환 안 되길 원했던 곳. Default 는 structural; brand 는 드문 case.

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

printName(x: { name: string }): void 함수 써. 그다음 전달해: (1) name 가진 plain object, (2) name 가진 class instance, (3) name 과 다른 property 가진 class instance, (4) name 빠진 plain object. 어느 게 컴파일 되고 어느 게 안 되고, 왜? 그다음 name 에 brand 추가해서 BrandedName 으로 만들고 이제 어떻게 constructor-made 값만 받아들여지는지 봐.
Hint
Structural typing 이 case 1, 2, 3 통과시킴. Case 4 만 실패 (빠진 필드). Brand 가 일반 object 의 raw string 'name' 전달 막아 — branded constructor 에서 와야 함.

Progress

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

댓글 0

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

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