오늘 얘기해 볼 것은 다중 상속. 클래스 상속은 아니고 퍼블릭 인터페이스 상속에 관련된 이야기이다. 지금부터 사용하는 "다중 상속"이라는 의미는 퍼블릭 인터페이스 다중 상속을 의미한다.
최근에 이러한 다중 상속을 복잡하게 사용할 일이 생겼는데, typescript 다중 상속을 생각보다 모르고 있다는 생각이 들어서 한번 정리하고 넘어가려고 한다. 가장 핵심은 리스코프 치환 원칙을 슈퍼 타입과 서브 타입이 만족해야한다는 것이다. 즉, 반드시 슈퍼 타입은 서브 타입으로 대체 가능해야한다. 이 원칙을 위배하면 슈퍼 타입으로 특정 로직을 처리하고 있는 클라이언트가 다룰 수 있는 서브 타입에 예외가 생기기 때문에 유연성이 떨어진다.
대충 이런 상황이었다. 우선 가장 상위에 Base 라는 타입을 두어 Base가 만족해야하는 최소한의 인터페이스를 선언하였다. 그리고 이를 상속한 BackwardcompatibleBase, CoreBase가 있고, 다시 이 둘을 상속하는 FetchedBase가 있다.
여기서 FetchedBase가 문제였다. 상속하기 위한 방법으로 interface extends와 intersection type을 고민했는데, 현재로서는 intersection type이 다중 상속하기 더 좋은 방법이라 생각하여 이를 사용했다. 이 둘의 차이점을 정리해보고자 한다.
타입스크립트에서 다중 인터페이스 상속은 두가지 방법으로 할 수 있다.
intersection type(&)와 interface extends다.
이름에서 알 수 있듯, 하나는 type을 사용하고 다른 하나는 interface를 사용한다.
공통점은 슈퍼 타입을 더 확장한 서브 타입을 만들 수 있다는 것인데, 차이점이 더 중요하니 차이점만 알아보자.
연산에 사용된 두 타입을 모두 포함함. (교집합)
ex.
interface Foo {
a: string[]
}
interface Bar {
a: number[]
}
type Intersection = Foo & Bar
// Intersection 결과
{
a: string[] & number[]
}
값을 기준으로 연산에 사용된 두 타입을 모두 포함한다. 즉 Intersection.a는 string[] 타입도 되어야하고, number[] 타입도 되어야하는 것이다.
하지만 string이면서 number인 타입이 있는지 생각해보자. 집합으로 생각한다면 두 타입은 교집합이 존재하지 않는다. 따라서 이 경우 never[] 타입으로 추론되어 문제가 발생한다. 치환 원칙을 위배하는 것이다.
interface extends의 경우 extends 할 때 상위 호환성을 만족하지 않으면 에러를 개발자에게 보여주며 실수를 줄여주지만 intersection type의 경우 그런 거 없다. 그냥 무조건 상속이 성공하니 전체 타입 검사를 해야 이를 알아낼 수 있다.
따라서 두 타입을 intersection 할 때는 두 타입의 속성 값이 교집합을 가지는지 주의하며 사용해야한다.
서브 타입에 extends하는 슈퍼 타입과 같은 속성 이름이 있으면 해당 속성을 덮어씌운다.(overwrite)
하지만 반드시 슈퍼 타입의 속성값 타입과 범위가 같거나 작아야 덮어씌우는 것이 가능하다. 예를 들면 다음과 같다.
ex.
interface Foo {
a: (string | number)[]
}
interface Extends extends Foo {
a: boolean[]
b: string
}
// Extends 결과
불가능. Extends 타입에서 오류가 난다.
결과적으로 놓고보면 intersection type과 동작은 비슷하다. 다만 interface는 슈퍼 타입과 서브 타입 속성값 간의 교집합이 존재하지 않으면 오류가 발생한다. 따라서 반드시 슈퍼 타입의 속성값 범위보다 같거나 작은 속성값만을 타입 지정할 수 있다. 다음과 같은 식이다.
interface Foo {
a: (string | number)[]
}
interface Extends extends Foo {
a: number[]
b: string
}
// Extends 결과
{
a: number[]
b: string
}
=> 타입을 좁힐 수 있음
=> 행동 호환성 만족
그렇다면 다중 상속은 어떻게 이루어지는지 확인해보자.
ex.
interface Foo {
a: (string | number)[]
}
interface Bar {
a: number[]
}
interface MultipleExtends extends Foo, Bar {}
// MultipleExtends 결과
Interface 'MultipleExtends' cannot simultaneously extend types 'Foo' and 'Bar'.
Named property 'a' of types 'Foo' and 'Bar' are not identical.
에러 발생함
예상되는 형태는 다음과 같지만 다음처럼 결과가 나오지는 않음
{
a: number[]
}
다중 상속은 가능은 하지만 문제가 있는 편이다. Foo와 Bar를 모두 상속받았다면 결과적으로는 MultipleExtends.a가 number[] 타입이 되어야할 것 같지만 Foo와 Bar가 동일하지 않다는 이유로 에러가 발생한다.
따라서 슈퍼 타입과 서브 타입 간 동일 속성의 타입을 맞춰주면 다중 상속이 가능하다.
interface Foo {
a: number[]
}
interface Bar {
a: number[]
}
interface MultipleExtends extends Foo, Bar {}
// 문제 없음.
왜 다중 상속이 이렇게 되는지에 대해서는 조금 더 찾아보아야겠지만 내가 원하는 방향은 아니었다. 다중 상속이 꽤나 제약이 걸리는 것으로 느껴졌다.
일단 내가 내린 결론은 다중 상속은 intersection type을 쓰자는 것이다. 개발자의 실수를 유발할 순 있지만, 타입 검사에 잡히지 않는 것도 아니며 다중 상속에 있어서 훨씬 더 자유롭기 때문이다.
공부하다보니 interface와 index signiture는 호환이 안된다는 사실도 알게되었다. 또, 타입을 좁히는 것이 서브 타이핑이라는 것도 머리론 이해하고 있는데, 넓히는 것이 서브 타이핑이라고 착각하는 경우도 많다 =ㅅ= 진짜 typescript를 아직도 잘 모르는 것 같다. 할 건 산더미구나 😭
이제는 아시겠지만.. 본래 다중상속은 다이아몬드 문제 때문에 클래스에서는 지원하지 않는 경우가 대부분입니다.
그래서 인터페이스처럼 동작을 정의하지 않는 것만 다중 상속을 허용합니다.
허용되지 않는 다종상속을 위해서 intersection type으로 다중상속을 하는 것은 추천하지 못하겠네요..