TypeScript
의 타입 시스템은 구조적 타이핑(Structural Typing
)을 기반으로 합니다.
구조적 타이핑은 변수의 타입을 변수의 이름을 통해 구분하는 명목적 타이핑(Nominal Typing
)과 다르게, 두 변수의 이름이 다르더라도 내부의 구조가 일치하면 같은 타입으로 간주하는 타입 시스템을 의미하는데요.
interface A {
name: string,
age: number
}
interface B {
name: string,
age: number
}
const b: B = { name: 'Jihan', age: 30 };
const a: A = b // ✅ 정상 (구조적 타이핑 적용)
위와 같이 변수 a
의 타입을 A
로 선언하고 앞서 할당한 B
타입의 값을 할당했음에도 불구하고, A
와 B
의 구조가 같기 때문에 같은 타입으로 간주하는 TypeScript
에서는 문제가 되지 않습니다.
class A {
String name = "Jihan";
int age = 30;
}
class B {
String name = "Jihan";
int age = 30;
}
A a = new B(); // ❌ 컴파일 오류
반면에 명목적 타이핑을 기반으로 하는 Java
의 경우, 위와 같이 타입 A
로 선언된 변수 a
에, new B()
로 생성한 B
타입의 값은 할당할 수 없습니다.
오늘 저희가 같이 공부해볼 개념은 이러한 구조적 타이핑 기반인 TypeScript
에서의 공변성
과 반공변성
입니다.
우리는 논리적 구조를 따질 때 서브셋과 슈퍼셋의 개념을 자연스럽게 적용합니다. Animal이라는 클래스와 Dog라는 클래스가 있다고 하면, 자연스럽게 Dog가 Animal에 포함되어 있는 집합 구조를 떠올리게 되죠.
그리고 우리는 이러한 논리적 구조를 코드로 변환하는 과정을 거치게 됩니다.
interface Animal {
name: string;
}
interface Dog extends Animal {
bark: () => string;
}
const animal: Animal = {
name: 'navi'
}
const dog: Dog = {
name: 'cherry',
bark: () => 'bark'
}
클래스 끼리의 상속 관계는 간단한 상속 문법으로 구현하게 되지만, 각 클래스를 파라미터 혹은 반환타입으로 하는 함수의 경우 다음과 같은 부분을 고려해보아야 합니다.
1-1. Dog를 파라미터로 받는 함수에서 Animal도 파라미터로 받을 수 있어야하는가?
1-2. Animal을 파라미터로 받는 함수에서 Dog도 파라미터로 받을 수 있어야하는가?
2-1. Dog를 반환타입으로 하는 함수에서 Animal을 반환할 수 있어야하는가?
2-2. Animal을 반환타입으로 하는 함수에서 Dog를 반환할 수 있어야하는가?
일반적인 함수의 구현은 위에서부터 (X,O,X,O)입니다. 안정성의 측면에서 바라보면 조금 더 쉽게 이해가 가능한데요.
Animal은 Dog보다 일반적인 클래스입니다. 즉, Animal이 가지고 있는 멤버는 Dog가 모두 가지고 있으며, Dog가 가지고 있는 멤버 중 일부는 Animal이 갖지 못할 수 있습니다.
const animalFn = (param: Animal) => {
// ...
console.log(param.name);
// ...
}
animalFn(animal) // ✅
animalFn(dog) // ✅
const dogFn = (param: Dog) => {
// ...
console.log(param.bark())
// ...
}
dogFn(animal) // ❌
dogFn(dog) // ✅
만약에 함수 파라미터로 특정 타입을 받으면, 함수 내부에서 그 멤버에 접근할 것입니다. 1-1의 경우, Dog에는 있지만 Animal에는 없는 멤버에 접근할 수 있기 때문에 불가해야 하지만, 1-2의 경우 Animal에 있는 멤버들은 모두 Dog에도 있기 때문에 문제가 생기지 않을 것입니다.
const animal:Animal = {
name:'navi'
}
const dog:Dog = {
name:'cherry',
bark:()=>'bark'
}
type AnimalFn = () => Animal
const animalFnA:AnimalFn = () => {
return animal; // ✅
}
const animalFnB:AnimalFn = () => {
return dog; // ✅
}
animalFnA().name;
animalFnB().name;
type DogFn = () => Dog
const dogFnA:DogFn = () => {
return animal; // ❌
}
const dogFnB:DogFn = () => {
return dog; // ✅
}
dogFnA().bark();
dogFnB().bark();
반환타입에서도 비슷한 생각을 해볼 수 있습니다. 함수가 반환한 이후에는 그 외부에서 반환값의 멤버에 접근하게 될텐데, 2-1의 경우 반환 이후에 Dog에만 존재하는 멤버에 접근하려고 했을 때 안정성에 문제가 생깁니다. 반면 2-2의 경우 Animal이 갖는 멤버는 Dog도 모두 갖기 때문에 문제가 생기지 않습니다.
정리해보자면 함수 파라미터 타입의 경우 정의된 타입의 서브셋은 허용되나 수퍼셋은 허용되지 않습니다. 반면에 함수 반환 타입의 경우 정의된 타입의 수퍼셋은 허용되나 서브셋은 허용되지 않습니다.
그리고 이 때 수퍼셋과 서브셋으로의 대체 가능성을 우리는 공변성
과 반공변성
이라고 부를 수 있습니다.
즉, 함수 파라미터 타입의 경우 반공변성이 적용되고 함수 반환 타입의 경우 공변성이 적용된다고 정리할 수 있습니다.
앞서 살펴본 것처럼, TypeScript에서는 타입 안정성을 위한 논리적 구조에 의해서 기본적인 함수 파라미터와 반환 타입에 변성이 적용됩니다.
그리고 ⛓️ TypeScript 4.7에서는 제너릭에서 in
, out
키워드를 통해 이러한 변성 구조를 명시적으로 드러낼 수 있게 되었습니다.
in
키워드의 경우 input
, 즉 함수 입력값이 되는 파라미터 타입의 반공변성을 명시할 수 있도록 해주고, out
키워드의 경우 output
, 즉 함수 출력값이 되는 반환 타입의 공변성을 명실할 수 있도록 해줍니다. 또한 in
, out
키워드를 함께 쓰는 경우에는 서브셋과 수퍼셋을 모두 허용하는 무공변성을 명시할 수 있습니다. 무공변성은 변성을 명시하지 않는 것과 사실상 동일합니다.
위 사진처럼 in을 선언했을 때는 제너릭 T 타입을 파라미터 타입으로만 사용할 수 있고, out을 선언했을 때는 제너릭 T 타입을 반환 타입으로만 사용할 수 있습니다.
마찬가지로 인터페이스에서도 제너릭에 키워드들을 명시해줄 수 있습니다.
각 키워드별로 조금 자세하게 살펴보겠습니다.
type CovarianceB<in T> = (param:T) => void;
interface Covariance<in T> {
consumer: (param:T) => void;
}
in T를 명시한다는 것은, 제너릭으로 받은 T 타입을 input의 용도로 사용할 것이고, 반공변성을 가진다고 명시하는 것과 같습니다. 따라서 반환 타입으로 반공변성을 가지게 구현하는 것은 우리가 앞서 살펴봤던 논리 구조상 타입 안정성을 보장받지 못하기 때문에 에러가 발생합니다.
type ContravarianceC<out T> = () => T;
interface Contravariance<out T> {
producer: () => T;
}
반대로 out T를 명시한다는 것은, 제너릭으로 받은 T 타입을 output의 용도로 사용할 것이고, 공변성을 가진다고 명시하는 것과 같습니다. 따라서 파라미터 타입으로 공변성을 가지게 구현하는 것은 타입 안정성을 보장받지 못하기 때문에 에러가 발생합니다.
type InvariantA<in out T> = (param:T) => T;
type InvariantB<in out T> = (param:T) => void;
type InvariantC<in out T> = () => T;
interface Invariant<in out T>{
producer: () => T;
consumer: (param:T) => void;
}
그리고 in out T를 명시하면, 제너릭으로 받은 T 타입을 input과 output의 용도로 모두 사용할 수 있다고 명시하는 것입니다. 따라서 반공변성을 갖는 파라미터 타입과 공변성을 갖는 반환 타입에 모두 사용할 수 있게 됩니다. 즉, 변성을 갖지 않는 무공변성 제너릭 타입을 명시하게 됩니다.
제너릭에 대한 in, out의 선언 자체가 실제 구현체의 동작에 차이를 만들어내지는 않습니다. 또한 in, out 키워드를 명시하지 않아도 TypeScript의 변성에 대한 동작은 일관적이기 때문에 구현할 시 적극적으로 활용하기에는 쉽지 않을 것 같습니다.
하지만 만약 제너릭으로 함수 인터페이스를 명시할 때, 제너릭의 용도를 제너릭 선언부에서부터 제한하도록 하여 복잡한 인터페이스 설계 시에 휴먼 에러를 최소화할 수 있는 형태로 보여집니다. 타입 자체를 작성하는 데에 있어서 안정성을 부여할 수 있다는 점이 장점인 것이지요.
복잡하고 비대한 인터페이스를 제너릭을 활용하여 정의할 때 꼭 써먹어봐야겠습니다.