타입스크립트와 구조적 타입 시스템
타입스크립트가 왜 구조적 타입 시스템을 도입했는지, 이를 어떻게 해석하고 사용할지 배웁니다

목표
타입스크립트의 구조적 타입 시스템 특성을 이해하고 타입 호환과 타입 체크를 연습합니다.
정적 타입 언어의 타입 시스템은 크게 두 가지 방식이 있습니다.
- 명목적 타이핑. (혹은 이름 기반 타입 시스템)
- 구조적 타이핑. (혹은 덕 타이핑, 프로퍼티 명 기반 타입 시스템)
둘의 차이는 아래와 같습니다.

- 명목적 타이핑은 '이름, 단위'를 비교합니다.
L
와m
는 단위가 다르므로 둘은 다르다고 판단합니다. - 구조적 타이핑은 '속성, 구조'를 비교합니다. 둘 다
number
속성을 가지므로 둘은 똑같다고 판단합니다.
벌써부터 '구조적 타입이 바보처럼' 보입니다. 객체 타입에선 어떨까요?
// 명목적 타이핑(이름 기반 타이핑): Java, Swift...
type Game = { name: string }
type Animal = { name: string }
let zelda: Game = { name: "zelda" }
let duck: Animal = { name: "gooose" }
zelda = duck; // ❌ Error: zelda는 Game 타입입니다! Animal 타입을 할당하지 마세요!
// 구조적 타이핑(속성 기반 타이핑): TypeScript...
type Game = { name: string }
type Animal = { name: string }
let zelda: Game = { name: "zelda" }
let duck: Animal = { name: "gooose" }
zelda = duck; // ✅ OK: "name" 속성을 만족하니까 할당 가능합니다!
"게임"과 "동물"은 엄연히 다릅니다. 하지만 둘 다 name
속성을 가졌단 이유만으로 서로 호환됩니다. 이런 특성 때문에 구조적 타이핑은 덕 타이핑이란 이름으로 많이 불립니다.
Q) “오리란 무엇인가?”
- 오리 같은 부리를 가졌다.
- 오리 같은 눈을 가졌다.
동물 협회에서 위 두 조건을 만족하기만 하면 오리로 공인했다고 가정해봅시다. 그럼 아래의 사진은 "합성이 아닌 진짜 오리"라고 인정됩니다.

왜 타입스크립트는 구조적 타입 시스템을 사용할까요?
구조적 타이핑은 허술하기만 할까요? 왜 이런 시스템을 허용할까요? 다음 상황을 봅시다.
type Food = { carbohydrates: number; protein: number; fat: number };
type Burger = Food & { burgerBrand: string };
// 명목적 타입 시스템에서 이 함수는 Food 타입만 처리합니다.
function calculateCalorie({ carbohydrates, protein, fat }: Food) {
return carbohydrates * 4 + protein * 4 + fat * 9;
}
const thighBurger: Burger = {
carbohydrates: 60,
protein: 28,
fat: 27,
burgerBrand: "Mom's Touch",
};
// Swift는 이럴 때 `Foodable protocol`을 `extension`으로 확장해서 사용합니다.
calculateCalorie(thighBurger); // 타입스크립트는 어떨까요? 함수가 버거 칼로리 계산을 못하게 막는 게 좋을까요?
직관적으로, 햄버거가 Food
타입은 아닐지언정 탄수화물, 단백질, 지방 정보가 있으니 칼로리 계산이 된다면 멋지지 않을까요? 실제로 타입스크립트는 별도의 처리 없이 칼로리 계산을 허용합니다. 햄버거가 탄수화물, 단백질, 지방이라는 Food의 '속성'을 전부 갖췄기 때문입니다.
심지어 버거 브랜드라는 잉여 속성도 허용합니다. 허술해보이던 단점을 유연함이라는 장점으로 승화시킵니다. 이런 특징은 다형성과 연계됩니다.
- 명목적 타이핑: 정밀한 타입 체크로 에러 방지. 유연성, 호환성이 부족
- 구조적 타이핑: 타입 체크가 허술하지만, 그래서 유연한 사용이 가능
구조적 타이핑은 유연함과 허술함이 공존합니다. 타입스크립트에는 허술함을 조심하는 기술이 몇 가지 있습니다. 그 중 하나는 객체 리터럴입니다.
먼젓번 코드에선 버거 브랜드 속성을 허용했지만 객체 리터럴을 바로 입력하면 에러가 발생합니다. 타입스크립트는 타입이 결정되거나 추론된(변수에 할당되거나 as로 타입 단언한) 객체만 잉여 속성을 허용하기 때문입니다.
타입스크립트는 객체 리터럴을 신선(fresh)한 객체라고 분류하며, 타입에 기재되지 않은 속성을 발견되면 에러를 던집니다. 이런 케이스를 허용했다간 개발자의 실수로 버그가 발생할 확률이 높기 때문입니다.
// 📒 만약 객체 리터럴에서 잉여 속성도 허용한다면?
// 😡 부작용 1: 다른 개발자는 burgerBrand가 필수 속성이라고 오해할 수 있습니다.
const calorie1 = calculateCalorie({
protein: 29,
carbohydrates: 48,
fat: 13,
burgerBrand: "버거킹", // ❌ Error: 잉여 속성이 존재함.
});
// 🤬 부작용 2: 잉여 속성에 오타가 있어도 TypeScript는 이를 발견할 수 없습니다!
const calorie2 = calculateCalorie({
protein: 29,
carbohydrates: 48,
fat: 13,
birgerBrand: "버거킹", // ❌ Error: 잉여 속성이 존재함.
});
아까 봤던 L과 m을 비교하려면 어떻게 할까요?
1000L
와 1000m
는 둘 다 number
타입이기 때문에 속성 비교만 하는 구조적 타이핑으론 방법이 없습니다. 이럴 때 개발자들은 브랜딩을 사용합니다. 브랜딩 기법은 명목적 타입 시스템을 구현하는 묘기입니다. 다시금 구조적 타입 시스템의 한계점을 확인해봅시다.
// Nominal Type System
type USD = { value: number };
type EUR = { value: number };
let dollors: USD = { value: 10; }
let euros: EUR = { value: 15; }
dollors = euros; // ❌ Error: USD와 EUR 타입은 다릅니다!
// Structural Type System
type USD = { value: number };
type EUR = { value: number };
let dollors: USD = { value: 10; }
let euros: EUR = { value: 15; }
dollors = euros; // ✅ OK: "value"라는 속성을 가지고 있으니 허용합니다...이러면 안 되는데?
명목적 타입 시스템에선 이런 케이스가 문제될 일이 없지만 구조적 타입만으론 이 문제를 다루기 쉽지 않습니다. 그래서 타입스크립트는 브랜딩이라는 명목적 타입을 흉내내는 기법을 사용합니다.
type Brand<K, T> = K & { __brand: T };
type USD = Brand<number, "dollors">;
type EUR = Brand<number, "euros">;
let dollors = 10 as USD; // number가 아닌 USD 타입으로 취급합니다.
dollors.__brand; // undefined. 숫자면서 __brand 속성도 가진 특이한 타입.
dollors = 20; // ❌ Error: 일반 숫자는 할당 불가능.
dollors = 1 as EUR; // ❌ Error: 이름이 다른 타입을 USD 타입에 할당할 수 없습니다.
dollors = 100 as USD; // ✅ OK: USD 타입만 재할당 가능
USD와 EUR은 실제 값은 숫자이면서 as로 EUR, USD라고 단언한 타입만 할당을 허용합니다. TypeScript 5.2.2 playground에서는 보안 위험이 있는 문자열 입력 등 "특별한 문자열, 숫자값을 그냥 처리하면 안 될 때" 쓰면 유용하다고 서술합니다.
아래는 브랜딩 기법을 활용해서 XSS(Cross-Site Scripting) 처리를 하는 간단한 예시입니다.
type ValidatedInputString = string & { __brand: "User Input Post Validation" };
// 입력 글자에 코드 실행문이 있다면 필터링하고, branding 처리한 string을 return합니다.
const validateUserInput = (input: string) => {
const simpleValidatedInput = input.replace(/\</g, "≤");
return simpleValidatedInput as ValidatedInputString;
};
// 검증된 branding 문자열만 받아서 출력합니다.
const printName = (name: ValidatedInputString) => {
console.log(name);
};
// 코드 실행하는 문자열
const input = "alert('bobby tables')";
// 검증을 거친 문자열
const validatedInput = validateUserInput(input);
printName(validatedInput); // ✅ OK: 검증 후 브랜딩 처리된 string이므로 정상 처리
printName(input); // ❌ Error: 검증 안 된(브랜딩이 안 된) 일반 string은 실행 거부
마무리
타입스크립트의 구조적 타입 시스템은 유연한 코드를 돕지만 개발자의 주의를 전제합니다. 많이 사용하면서 익숙해집시다.
참조
- 조현영(2023.08). 타입스크립트 교과서. 길벗
- 김병묵(2022.10). TypeScript 타입 시스템 뜯어보기: 타입 호환성. toss tech
- TypeScript 공식