1. 개요

Java 나 Kotlin 을 먼저 배운 사람이 TypeScript 를 처음 만지면 한 번은 당황하는 지점이 있다. 분명히 다른 이름으로 선언한 타입인데 서로 대입이 된다. class 도 아니고 interface 로 선언했는데 implements 한 적도 없는 객체가 그 타입으로 통과한다. 자바 머리로는 “타입이 같다고 선언한 적이 없는데 왜 되지” 싶다.

이건 버그가 아니라 TypeScript 타입 시스템의 근본 설계다. 구조적 타이핑(structural typing). 이 시리즈는 TypeScript 의 타입 시스템을 기초부터 중급까지 훑는데, 그 첫 편은 이 개념부터 짚고 가야 나머지가 다 이해된다. 앞으로 다룰 제네릭, 유니온, 유틸리티 타입 전부 “타입은 구조다” 라는 전제 위에 서 있다.


2. 핵심 내용

2-1. 명목적 타이핑: 이름이 곧 타입이다

Java 나 Kotlin 은 명목적 타이핑(nominal typing) 을 쓴다. 두 클래스의 필드가 완전히 똑같아도 이름이 다르면 다른 타입이다.

class Dog(val name: String, val legs: Int)
class Cat(val name: String, val legs: Int)
 
fun printLegs(d: Dog) = println(d.legs)
 
val c = Cat("nabi", 4)
printLegs(c) // 컴파일 에러: Cat 은 Dog 가 아니다

Dog 와 Cat 은 모양이 같아도 컴파일러가 “너는 Dog 라고 선언한 적 없잖아” 라며 막는다. 타입 호환 여부는 선언 계보(상속·구현)로 정해진다.

2-2. 구조적 타이핑: 모양이 같으면 같다

TypeScript 는 반대다. 이름이 아니라 가진 프로퍼티의 모양으로 호환 여부를 판단한다.

interface Dog {
  name: string
  legs: number
}
 
interface Cat {
  name: string
  legs: number
}
 
function printLegs(d: Dog) {
  console.log(d.legs)
}
 
const c: Cat = { name: "nabi", legs: 4 }
printLegs(c) // 정상 동작. Cat 이 Dog 의 모양을 "만족"하기 때문

Cat 이 Dog 를 상속하거나 구현한 적은 전혀 없다. 그런데도 printLegs 에 넘길 수 있다. TypeScript 는 이름표를 안 본다. Dog 자리에 들어올 값이 name: string, legs: number 를 갖고 있으면 그걸로 충분하다고 판단한다. 심지어 타입을 아예 선언하지 않고 객체 리터럴만 넘겨도 된다.

printLegs({ name: "poppy", legs: 4 }) // 이것도 통과

이걸 흔히 “오리 타이핑(duck typing)” 이라 부르는데, 정확히는 런타임이 아니라 컴파일 타임에 정적으로 검사하는 오리 타이핑이라 구조적 타이핑이라는 이름이 더 정확하다.

2-3. 왜 이렇게 설계했나

TypeScript 는 자바스크립트에 타입을 얹은 언어다. 자바스크립트에는 애초에 implements 같은 게 없다. 객체는 그냥 프로퍼티 뭉치이고, 함수는 “이런 모양의 인자를 받는다” 는 암묵적 계약으로 움직인다. jQuery 시절 코드, DOM 이벤트 객체, JSON 으로 받은 응답, 이 모든 게 특정 클래스를 상속한 적이 없다. 명목적 타이핑을 강제했다면 기존 자바스크립트 생태계 전체에 타입을 붙이는 게 사실상 불가능했을 것이다. 구조적 타이핑은 “이미 있는 자바스크립트 코드에 최대한 자연스럽게 타입을 씌운다” 는 TypeScript 의 목표에서 나온 선택이다.

2-4. 편한 점: 어댑터가 필요 없다

여러 라이브러리가 각자 비슷한 모양의 옵션 객체를 요구할 때, 명목적 언어라면 매번 어댑터 클래스나 변환 함수가 필요하다. TypeScript 에서는 모양만 맞으면 그대로 전달된다.

type Point = { x: number; y: number }
 
function distance(a: Point, b: Point): number {
  return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
}
 
const mouseEvent = { x: 10, y: 20, button: 0 }
distance(mouseEvent, { x: 0, y: 0 }) // button 은 무시되고 통과

mouseEvent 에 button 이라는 여분 필드가 있어도 문제없다. Point 가 요구하는 최소 모양만 갖췄으면 된다. 이건 함수 시그니처를 설계할 때 “내가 실제로 뭘 쓰는지” 만 타입으로 표현하면 된다는 뜻이기도 하다. 불필요하게 큰 타입을 요구할 필요가 없다.

2-5. 헷갈리는 점: 객체 리터럴만 예외적으로 엄격하다

방금 예제에서 mouseEvent 라는 변수를 거쳐 넘기면 통과하는데, 같은 값을 리터럴로 바로 넘기면 에러가 난다.

distance({ x: 10, y: 20, button: 0 }, { x: 0, y: 0 })
// Error: 개체 리터럴은 알려진 속성만 지정할 수 있으며 'button'은
// 'Point' 형식에 없습니다.

이게 초과 프로퍼티 검사(excess property check) 다. 구조적 타이핑 원칙만 따지면 이것도 통과해야 맞는데, TypeScript 는 리터럴을 직접 넘기는 경우엔 오타를 잡기 위해 예외적으로 더 엄격하게 본다. { nmae: "typo" } 처럼 오타 낸 필드가 “여분 프로퍼티는 무시한다” 는 구조적 타이핑 규칙에 가려 조용히 넘어가는 걸 막으려는 안전장치다. 변수에 담아서 넘기면 이 검사를 우회한다는 것도 같이 알아둘 부분이다.

2-6. 또 하나의 함정: 빈 인터페이스는 거의 모든 걸 받는다

구조적 타이핑을 극단으로 밀면, 프로퍼티 요구가 없는 타입은 사실상 아무 객체나 다 받는다.

interface Config {}
 
function setup(c: Config) { /* ... */ }
 
setup(42) // 원시값이 아니면 대부분 통과 (단, 원시 타입은 안 됨)
setup({}) // 통과
setup({ anything: "goes" }) // 통과

“필드가 없는 타입” 은 “아무 필드나 있어도 된다” 는 뜻이지 “필드가 없어야 한다” 는 뜻이 아니다. 빈 인터페이스로 “아직 구체화 안 된 타입” 을 표현하려다 이 함정에 걸리는 경우가 잦다.


3. 이 시리즈에서 다룰 것

  • 2편: 유니온·인터섹션과 typeof·in·사용자 정의 타입 가드로 좁히기
  • 3편: <T> 가 해결하는 문제와 extends 제약, 흔히 any 로 도망치는 지점
  • 4편: Partial, Pick, Omit, Record 를 언제 쓰는지
  • 5편: 선언 병합, 유니온 표현 가능 여부 같은 실질적 차이
  • 6편: 타입 체크를 포기하는 것과 확인 전엔 못 쓰게 막는 것
  • 7편: is 키워드로 런타임 검증과 컴파일 타임 타입을 잇는 법

요약

  • Java/Kotlin 은 이름(상속·구현 계보)으로 타입을 구분하는 명목적 타이핑, TypeScript 는 프로퍼티 모양으로 구분하는 구조적 타이핑이다.
  • 구조적 타이핑 덕분에 관계없는 두 타입도 모양만 맞으면 서로 대입되고, 별도 어댑터 없이 라이브러리 타입을 섞어 쓸 수 있다.
  • 객체 리터럴을 직접 넘길 때만 예외적으로 초과 프로퍼티 검사가 걸려 오타를 잡아준다. 변수를 거치면 우회된다.
  • 빈 인터페이스 {} 는 “아무 필드나 허용” 이라는 뜻이라 거의 모든 값을 받아버린다.

다음은 2편이다. 유니온과 인터섹션, 그리고 타입 좁히기(narrowing)를 다룬다.