TypeScript와 구조적 타이핑
덕 타이핑과 구조적 타이핑, 그리고 명목적 타이핑 맛보기

서론
JavaScript는 동적 타이핑(dynamic typing)을 사용하여 런타임
에 타입이 결정되며, 객체가 가진 메소드나 속성에 따라 동작이 결정됩니다.
이런 개념을 덕 타이핑(Duck Typing)이라고 하는데,
"만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다"라는 말에서 나온 개념으로, 오리처럼 행동하면 오리로 본다 해서 덕 타이핑이라 불린다 합니다.
TypeScript는 정적 타입 검사를 도입하면서 구조적 타이핑(Structural Typing)이라는 방식을 사용해 타입 체크를 합니다. 구조적 타이핑은 마치 컴파일 타임
에 적용되는 덕 타이핑과 같다고 볼 수 있습니다.
이번 글에서는 JavaScript의 덕 타이핑 동작 방식과 TypeScript의 구조적 타이핑 개념을 정리해보려 합니다.
덕 타이핑(Duck Typing)이란
덕 타이핑의 개념과 동작 방식
덕 타이핑(Duck Typing)은 객체의 실제 타입보다는 객체가 제공하는 메소드와 속성에 따라 그 객체를 특정 타입으로 간주하는 방식입니다.
객체의 클래스나 생성자를 확인하지 않고 해당 객체가 필요한 메소드/속성을 가지고 있느냐에 따라 적합성을 판단합니다.
클래스 상속이나 인터페이스 구현으로 타입을 구분하는 대신, 객체가 어떤 타입에 요구되는 변수와 메소드를 모두 지니고 있다면 그 객체를 해당 타입으로 취급하는 것입니다.
JavaScript는 정적 타입 검사기가 없는 동적 언어이므로, 코드에서 어떤 객체를 사용할 때 그 객체의 타입을 미리 선언하지 않습니다.
대신 필요한 메소드나 프로퍼티가 존재하는지 여부에 따라 런타임
에 동작이 결정됩니다.
예를 들어 JavaScript에서 함수에 객체를 인자로 전달할 때, 그 객체에 특정 메소드가 있다면 함수 내에서 호출이 가능하고, 없으면 런타임 에러가 발생합니다.
JavaScript의 덕 타이핑
덕 타이핑은 다형성(polymorphism)을 구현하는데 활용됩니다.
서로 다른 타입의 객체라 하더라도, 필요한 메소드만 구현되어 있으면 동일하게 취급할 수 있기 때문입니다.
// 'quack' 메소드를 가진 어떤 객체든 사용 가능
function makeItQuack(duck) {
duck.quack(); // 전달된 객체에 quack 메소드가 있다고 가정하고 호출
}
const realDuck = {
quack: () => console.log("오리는 꽥꽥~")
};
const personImitatingDuck = {
name: "김김김",
quack: () => console.log("오리는 냠냠~")
};
makeItQuack(realDuck); // "오리는 꽥꽥~"출력
makeItQuack(personImitatingDuck); // "오리는 냠냠~"출력
위 코드에서 makeItQuack
함수는 인자로 받은 duck
객체에 quack()
메소드가 있을 것이라고 가정하고 호출합니다.
realDuck
과 personImitatingDuck
객체는 서로 타입이 다르지만, 둘 다 quack
메소드를 가지고 있으므로 함수 호출이 정상적으로 이루어집니다.
이처럼 메소드가 존재하면 호출한다는 식으로 객체의 실제 타입은 중요하지 않고 해당 메소드의 존재 여부가 중요한 것이 덕 타이핑의 핵심입니다.
만약 quack
메소드를 가지지 않은 객체를 전달하면 에러가 발생할 것입니다.
예를 들어 makeItQuack({ say: () => console.log("Hello hell..") })
를 호출하면 duck.quack
에서 undefined 오류가 발생합니다.
결국 JavaScript에서는 객체가 필요한 기능을 구현했는지만 확인하고 그에 따라 동작하는 것이 덕 타이핑의 구현이라고 할 수 있습니다.
구조적 타이핑(Structural Typing)이란
구조적 타이핑의 개념과 컴파일 타임 덕 타이핑
TypeScript는 구조적 타이핑을 통해 정적으로 타입을 체크합니다. 구조적 타이핑이란 객체의 타입을 결정할 때 이름보다는 객체가 가진 구조(프로퍼티와 메소드의 형태)에 따라 판단하는 방식입니다.
TypeScript에서는 값의 형태(shape)가 곧 타입 호환성의 기준이 됩니다.
TypeScript 공식 문서에서도 "TypeScript의 핵심 원칙 중 하나는 타입 체크가 값이 가진 모양(shape)에 집중한다"고 명시하며, 이는 흔히 덕 타이핑
혹은 구조적 서브타이핑
이라고 불린다고 설명합니다.
즉, TypeScript의 타입 시스템은 정적 분석 단계에서 JavaScript의 덕 타이핑 개념을 모델링한 것이라 볼 수 있습니다.
예를 들어, TypeScript에서 어떤 인터페이스나 타입이 요구하는 프로퍼티들을 모두 가진 객체는 그 인터페이스 타입으로 간주되어 할당 가능합니다.
별도로 해당 인터페이스를 implements
키워드로 구현하지 않았더라도 구조만 맞으면 호환된다는 뜻입니다.
TypeScript의 구조적 타이핑
이러한 특징 덕분에 TypeScript에서는 객체 리터럴이나 익명 객체를 함수에 바로 전달할 수 있고, 타입을 일치시키기 위한 추가적인 클래스를 만들 필요 없이 필요한 속성과 메소드만 갖추면 동일한 타입으로 취급됩니다.
아래 코드에서 인터페이스 Labeled
와 클래스 Product
는 명시적으로 관계를 맺고 있지 않지만, 동일한 형태의 프로퍼티를 가지기 때문에 구조적 타이핑에 의해 서로 호환됩니다.
interface Labeled {
label: string;
}
class Product {
label: string;
// ... (다른 속성이나 메소드가 있을 수 있음)
}
let item: Labeled;
item = new Product(); // 오류 없음: 구조적 타이핑에 의해 호환됨
Product
클래스는label: string
프로퍼티를 갖고 있습니다.Labeled
인터페이스도 동일하게label: string
프로퍼티를 요구합니다.
TypeScript에서는 Product
가 Labeled
를 구현한다고 선언하지 않았지만, 구조적으로 같은 형태를 가지므로 new Product()
객체를 Labeled
타입의 변수 item
에 할당할 수 있습니다.
이 할당은 컴파일 타임에 검사되며, Product
에 label
프로퍼티가 존재하기 때문에 타입 오류가 발생하지 않습니다.
TypeScript 컴파일러는 item = new Product()
코드를 검사하면서 Product
가 최소한 Labeled
와 동일한 멤버(label
)를 가지고 있으므로 호환 가능하다고 판단하는 것입니다.
이러한 동작은 구조적 타입 시스템의 기본 규칙에 따른 것으로, 공식 문서에서는 이를 "x가 y의 모든 멤버를 가지고 있다면 x는 y 타입과 호환된다"라고 표현합니다.
또한 함수의 매개변수 타입 검사에서도 구조적 타이핑이 적용됩니다. 예를 들어, 다음 코드에서 printName
함수는 nameType
의 객체를 요구하지만, 추가 속성이 있어도 호출이 가능합니다.
type personType = {
name: string
age: number
}
type nameType = {
name: string
}
function printName(obj: nameType) {
console.log(obj.name)
}
const personObj: personType = { name: "이이이", age: 27 }
printName(personObj) // 오류 없음
이 경우 personObj
객체는 name: string
프로퍼티를 포함하고 있으므로 printName
의 매개변수 타입과 호환됩니다.
personType
에 age
라는 추가 속성이 있지만, TypeScript는 필요한 프로퍼티만 존재하면 추가 프로퍼티는 무시하고 타입을 맞춰줍니다.
명목적 타이핑(Nominal Typing)이란
명목적 타이핑은 C++, Java, C#과 같이 타입의 이름이나 선언을 기준으로 호환성을 결정하는 방식입니다.
이러한 언어에서는 두 타입이 호환되려면 명시적으로 관계를 맺어야 합니다. 예를 들어 Java에서 어떤 클래스의 인스턴스를 인터페이스 타입 변수에 할당하려면, 그 클래스가 해당 인터페이스를 implements
로 구현하고 있어야 합니다.
클래스의 프로퍼티 구조가 같다고 해도 구현 관계가 선언되어 있지 않으면 타입 호환이 되지 않습니다.
TypeScript에서는 아래와 같이 상속 관계를 명시하여 명목적 타이핑의 타입 호환을 허용할 수 있습니다.
//상속 관계 명시
type personType = nameType & {
age: number
}
type nameType = {
name: string
}
function printName(obj: nameType) {
console.log(obj.name)
}
const personObj: personType = { name: "박박박", age: 27 }
printName(personObj) // 오류 없음
구조적 타이핑과 명목적 타이핑의 차이점
반면 TypeScript를 비롯한 Go, Scala 등의 구조적 타입 언어에서는 타입 이름과 무관하게 구조가 같으면 호환이 됩니다. TypeScript 공식 문서에서는 "TypeScript의 타입 시스템은 구조적이며, 명목적이 아니다"라고 밝히며, 타입들 간의 관계는 선언된 관계가 아니라 그들이 포함하는 프로퍼티에 의해 결정된다고 설명합니다.
구조적 타이핑의 장점은 유연성과 개발 편의성입니다. 인터페이스를 굳이 구현하지 않았더라도 필요한 곳에 객체를 바로 전달할 수 있습니다.
JavaScript에서는 함수 표현식이나 객체 리터럴 등 익명 객체를 광범위하게 사용하기 때문에 이러한 관계를 표현하기에 구조적 타입 시스템이 더 자연스러웠다고 합니다
명목적 타이핑의 장점은 타입의 의미를 보다 분명히 하고, 서로 다른 의미의 타입이 우연히 섞이는 것을 방지해주는 것을 들 수 있습니다.
구조적 타이핑에서 발생할 수 있는 타입 에러
구조적 타이핑은 편리하지만, 때로는 예상치 못한 타입 호환으로 인해 버그가 발생할 여지가 있습니다.
예상치 못한 타입 호환 문제 사례
객체 리터럴의 타입 에러
아래와 같이 printName
호출하면 개체 리터럴은 알려진 속성만 지정할 수 있으며 'nameType' 형식에 'age'이(가) 없습니다.
와 같은 타입 에러가 발생합니다.
type nameType = {
name: string
}
function printName(obj: nameType) {
return "너의 이름은" + (obj.name)
}
const name = printName({ name: "최최최", age: 27 }) // 타입 오류 발생
해당 에러의 발생 이유는 https://toss.tech/article/typescript-type-compatibility 해당 글에서 자세하게 확인하실 수 있습니다.
간략히 요약하자면, 해당 에러는 타입 검사와 가장 연관이 높은 checker.ts 파일의 hasExcessProperties()
함수에서 발생시킨 것입니다.
/** 객체 리터럴을 매개변수로 전달할 경우 true가 됩니다. */
const isPerformingExcessPropertyChecks =
getObjectFlags(source) & ObjectFlags.FreshLiteral;
if (isPerformingExcessPropertyChecks) {
/** hasExcessProperties() 함수는
* excess property(여기서는 age)가 있는 경우 에러를 반환하게 됩니다.
* 즉, property가 정확히 일치하는 경우만 타입을 허용합니다 */
if (hasExcessProperties(source as FreshObjectLiteralType)) {
reportError();
}
}
함수의 매개변수로 들어온 값이 FreshLiteral
인지의 여부에 따라 조건 분기가 발생하여 타입 호환 허용 여부가 결정됩니다.
FreshLiteral
이란, TypeScript는 구조적 서브타이핑에 기반한 타입 호환의 예외 조건과 관련하여 신선도 (Freshness) 라는 개념을 제공합니다.
모든 객체 리터럴은 초기에 fresh
하다고 간주되며, 타입 단언을 하거나, 타입 추론에 의해 객체 리터럴의 타입이 확장되면 freshness
가 사라지게 됩니다. 특정한 변수에 객체 리터럴을 할당하는 경우 이 2가지 중 한가지가 발생하게 되므로 freshness
가 사라지게 되어 타입 호환의 예와가 발생하지 않습니다. 하지만 매개변수로 객체 리터럴을 바로 전달하는 경우에는 fresh
한 상태로 전달이 되기 때문에 타입 에러가 발생하게 됩니다.
이렇게 FreshLiteral
에 따라 조건 분기처리를 하는 이유는 아래와 같은 부작용 때문이라고 합니다.
/** 부작용 1
* 코드를 읽는 다른 개발자가 printName 함수가
* age를 사용한다고 오해할 수 있음 */
const name = printName({ name: "최최최", age: 27 })
/** 부작용 2
* aeg 라는 오타가 발생하더라도
* excess property이기 때문에 호환에 의해 오류가
* 발견되지 않음 */
const name = printName({ name: "최최최", aeg: 27 })
매개변수로 객체 리터럴을 사용하면 어차피 호출한 함수에서만 사용됩니다.(다른 변수에 할당되어 재사용되지 않음) 구조적 타이핑의 유연함에 대한 이점보다는 부작용을 발생시킬 가능성이 높아 지기 때문에 지원할 필요가 없습니다.
동일한 구조의 다른 의미 타입
인터페이스나 타입 별칭으로 정의한 객체 타입이 동일한 프로퍼티들을 가져서 서로 호환되는 경우입니다.
interface User {
id: string;
name: string;
}
interface Product {
id: string;
name: string;
}
function getProductLabel(product: Product) {
return `상품(${product.id}): ${product.name}`;
}
const user: User = { id: "1", name: "정정정" };
console.log(getProductLabel(user)); // 컴파일 오류 없음 (런타임 논리 오류 가능)
User
와 Product
는 의미적으로 전혀 다르지만, 구조적으로 { id: string, name: string }
으로 동일하기 때문에, getProductLabel
함수에 User
객체를 전달해도 오류가 발생하지 않습니다.
그러나 런타임에서는 user
객체를 받아들였기 때문에 함수 내부의 의미상 잘못된 동작이나 잘못된 데이터 처리가 이루어질 수 있습니다.
빈 객체 또는 부분 객체의 호환
구조적 타이핑에서는 빈 구조(empty)는 모든 객체 타입의 상위 타입처럼 취급됩니다.
type Empty = {}
function processEmpty(obj: Empty) {
// ...
}
processEmpty({ name: "강강강" }) // 빈 구조와 호환되므로 오류 없음
processEmpty({ id: 123 }) // 마찬가지로 오류 없음
Empty
타입은 아무 프로퍼티도 없으므로 { name: "정정정" }
, { id: 123 }
같은 객체 리터럴은 Empty
가 요구하는 프로퍼티를 모두 가지고 있다고 간주됩니다.
구조적 타이핑의 극단적인 예시.. 이지만 빈 인터페이스나 빈 클래스를 사용할 때 생길 수 있는 혼란이자 구조적적 타이핑의 동작 원리를 나타냅니다.
선택적 프로퍼티의 모호함
TypeScript에서는 프로퍼티를 optional
로 지정할 수 있는데, 구조적 타이핑에서는 선택적 프로퍼티가 있는 타입과 없는 타입 간의 호환이 직관과 다를 수 있습니다.
interface Config {
timeout?: number // 선택적 프로퍼티
}
const conf: Config = { timeout: undefined } // 선택적 프로퍼티에 값을 할당 (허용됨)
위 코드에서 timeout?: number
는 없을 수도 있는 숫자 타입 프로퍼티를 의미합니다.
그런데 { timeout: undefined }
를 할당해도 기본적으로 오류가 아닙니다.
그러나 개발자 입장에서는 “프로퍼티가 없다”와 “프로퍼티 값이 undefined 이다”는 다르게 받아들여질 수 있습니다. 이처럼 선택적 프로퍼티의 구조적 타이핑 해석 때문에 예상치 못한 호환이 일어나거나, undefined
처리를 빼먹는 실수가 발생할 수 있습니다.
문제 해결 및 예방 방법
위와 같은 문제들을 완전히 방지하기는 어렵지만, TypeScript는 개발자가 타입 검사 규칙을 엄격하게 조절할 수 있는 여러 옵션과 패턴을 제공합니다:
branded type
구조적 타이핑 환경에서 명목적 타입 구분이 필요하다면 브랜딩(branding) 기법이나 private
필드를 활용할 수 있습니다. 예를 들어 각 타입에 실제 사용과 무관한 고유한 private
프로퍼티를 추가하면, 해당 프로퍼티 때문에 구조가 달라져서 다른 타입과 호환되지 않게 만들 수 있습니다.
또는 리터럴 타입과 교차 타입을 이용해 type UserId = string & { __brand: "UserId" };
처럼 브랜드 태그를 붙여주면, 컴파일러는 단순한 string
과 구분된 별도의 타입으로 인식하여 잘못된 호환을 막을 수 있습니다.
컴파일러 엄격 모드 사용
TypeScript 컴파일러의 -strict
옵션을 활성화하면 여러 엄격한 타입 검사 규칙이 적용되어 잠재적인 오류를 사전에 방지할 수 있습니다.
strict: true
를 설정하면 noImplicitAny
(암시적 any 금지), strictNullChecks
(엄격한 null 체크), strictFunctionTypes
(함수 타입 비교 시 공변/반공변 규칙 엄격 적용) 등을 포함한 모든 엄격 모드 옵션이 켜집니다.
strict 모드를 사용하면, 잘못된 타입 사용이나 누락된 체크를 컴파일 타임에 철저히 잡아내므로 구조적 타이핑으로 인한 실수를 줄이는 데 도움이 됩니다.
옵셔널 프로퍼티에 대한 정확한 타입 검사
TypeScript 4.4 버전부터 도입된 exactOptionalPropertyTypes
컴파일러 옵션을 사용하면, 선택적 프로퍼티를 보다 엄밀하게 다룰 수 있습니다. 이 옵션을 켜면 옵셔널(?)로 선언된 프로퍼티에 undefined
를 할당하는 것을 허용하지 않도록 동작을 바꿀 수 있습니다.
마지막으로, tsconfig.json
에서 어떤 옵션들을 켤지 결정해야 하는 것은 개발자 몫인 거 같습니다. 팀이나 프로젝트의 성격에 따라 타입 엄격도의 트레이드오프가 존재하니까요…
레퍼런스
- https://toss.tech/article/typescript-type-compatibility
- TypeScript: Documentation - TypeScript for Java/C# Programmers
- TypeScript: Documentation - Type Compatibility
- TypeScript: Documentation - TypeScript for Java/C# Programmers
- https://github.com/Microsoft/TypeScript/pull/3823
- TypeScript: TSConfig Option: strict
- TypeScript: TSConfig Option: exactOptionalPropertyTypes
회고
TypeScript의 구조적 타이핑에 대해 전혀 모르고 있었습니다. 모의면접 때 처음 이 질문을 받고 언어를 공부한게 아니라 프레임워크를 공부하고 있었단 걸 깨닫게 되었습니다.. 42서울에서 2년간 뭘 공부한걸까.. 반성도 했습니다.
React, Next.js와 같은 라이브러리, 프레임워크 공부와 더불어 언어에 대한 공부도 병행할 생각에 행복해졌습니다.