형태와 배열 가변성
복합 타입의 서브 타입 규칙은 왜 언어마다 다를까요?
이번 글에서 소개하는 복잡한 타입(형태)을 예로 살펴보겠습니다.
일단 응용 프로그램에 사용자를 묘사하는 형태가 있다고 가정하고, 다음처럼 두 가지 타입으로 표현한다고 해봅시다.
// 서버로부터 받은 기존 회원
type ExistingMemer = {
id: number
nickName: string
}
// 아직 서버에 저장하지 않은 새 회원
type NewMember = {
nickName: string
}
어떤 동아리의 회원 관리를 하게 되었는데 회원을 삭제하는 코드를 구현하는 역할을 맡았으면 다음과 같이 구현할 수 있습니다.
// 서버로부터 받은 기존 회원
type ExistingMemer = {
id: number;
nickName: string;
};
// 아직 서버에 저장하지 않은 새 회원
type NewMember = {
nickName: string;
};
function deleteMember(Member: { id?: number; nickName: string }) {
delete Member.id;
}
let existingMemer: ExistingMemer = {
id: 990221,
nickName: 'Cozy Linda'
}
deleteMember(existingMemer)
deleteMember는 {id?: number, nickName: string} 타입의 객체를 받도록 정의되었고 {id: number, nickName: string}이라는 existingMember 타입을 인수로 전달합니다.
id 프로퍼티의 타입(number)은 기대되는 타입(number | undefined)의 서브 타입이라는 사실에 주목합니다.
따라서 전체 객체 {id: number, nickName: string}은 {id?: number, nickName: string} 타입의 서브 타입이므로 TypeScript는 아무런 에러를 발생시키지 않습니다.
안전성에 작은 문제 한 가지가 있습니다.
ExistingMember를 deleteMember로 전달해 삭제한 뒤에도 TypeScript는 member의 id가 삭제된 사실을 알지 못합니다.
따라서 deleteMember(existingMember)로 id를 삭제한 다음 existingMember.id를 읽으면 TypeScript는 여전히 existingMember.id가 number 타입일 것이라고 생각합니다.
어떤 객체를 슈퍼 타입을 기대하는 곳에 사용한다는 것은 분명 안전하지 않을 수 있습니다.
그렇다면 왜 TypeScript는 이를 허용할까요?
전반적으로 TypeScript는 완벽한 안전성을 추구하도록 설계되진 않았습니다.
완벽함보단 실제 실수를 잡는 것과 쉬운 사용이라는 두 가지 목표를 균형 있게 달성하는 것이 TypeScript 타입 시스템의 목표입니다.
안전성이 보장되지 않는 이러한 특정 상황은 실용적인 면에서 타당성이 있습니다.
프로퍼티 삭제 같은 파괴적인 갱신은 실무에서 비교적 드물게 일어나므로 TypeScript는 이를 적극 제지하지 않고 슈퍼 타입이 필요한 곳에 객체를 할당할 수 있도록 허용합니다.
서브 타입이 필요한 곳에 할당은 어떠할까요?
기존 회원(legacy member)이라는 새로운 타입을 추가한 다음 이 타입의 회원을 삭제합니다.
type LegacyMember = {
id?: number | string
nickName: string
}
let legacyMember: LegacyMember = {
id: '990221',
nickName: 'Cozy Linda'
}
//'LegacyMember' 형식의 인수는 '{ id?: number | undefined; nickName: string; }' 형식의 매개 변수에 할당될 수 없습니다.
// 'id' 속성의 형식이 호환되지 않습니다.
// 'string | number | undefined' 형식은 'number | undefined' 형식에 할당할 수 없습니다.
// 'string' 형식은 'number' 형식에 할당할 수 없습니다.ts(2345)
deleteMember(legacyMember)
기대하는 타입의 슈퍼 타입의 프로퍼티를 포함하는 형태를 전달하면 TypeScript는 에러를 발생시킵니다.
슈퍼 타입의 id는 string | number | undefined인데 반해 deleteMember는 id가 number | undefined인 상황만 처리할 수 있기 때문입니다.
공변(covariant)
TypeScript는 다음처럼 동작합니다.
어떤 형태를 요구할 때 건넬 수 있는 타입은, 요구되는 타입에 포함된 프로퍼티 각각에 대해 '<: 기대하는 타입'인 프로퍼티들을 가지고 있어야 합니다.
기대하는 프로퍼티 타입의 슈퍼 타입인 프로퍼티가 있다면 건넬 수 없습니다.
타입과 관련해 TypeScript 형태 즉 객체와 클래스는 그들의 프로퍼티 타입에 공변(covariant)한다고 말합니다.
즉, 객체 B에 할당할 수 있는 객체 A가 있다면 '객체 A의 각 프로퍼티 <: B의 대응 프로퍼티'라는 조건을 만족해야 합니다.
공변은 가변성의 네 종류 중 하나입니다.
- 불변(invariance)
- 정확히 T를 원함
- 공변(covariance)
- <:T를 원함
- 반변(contravariance)
- >:T를 원함
- 양변(bivariance)
- <: T 또는 >:T를 원함
TypeScript에서 모든 복합 타입의 멤버(객체, 클래스, 배열, 함수, 반환 타입)는 공변이며 함수 매개변수 타입만 예외적으로 반변입니다.
'👶 TypeScript' 카테고리의 다른 글
할당성(assignability) (0) | 2023.01.18 |
---|---|
함수 가변성 (0) | 2023.01.18 |
가변성 (0) | 2023.01.17 |
서브 타입과 슈퍼 타입 (0) | 2023.01.17 |
디자인 패턴 - 빌더 패턴(builder pattern) (0) | 2023.01.17 |