2024년 10월 19일
최근에 타입스크립트를 사용하면서 지금껏 생각하지 못한 개념을 알게 됐습니다. 일반 변수와 함수의 파라미터에 대한 타입의 상속관계에 차이가 있다는 것입니다. 이번 글에서는 가변성(Variance)을 통해 두 개의 타입에 대한 상속관계를 알아보고 변수와 다르게 함수의 파라미터의 가변성이 다른 이유를 정리하려고 합니다.
가변성(Variance)에 대해 알아보기 전에 서브타입과 슈퍼타입을 간략히 알아보고자 합니다. 어떠한 타입 간에는 상속관계가 존재합니다. 아래의 그림을 보면 타입 A는 타입 B를 포함하고 있습니다. 이때 타입 A를 타입 B의 슈퍼타입이라고 하며, 타입 B는 타입 A의 서브타입이라고 합니다.
슈퍼타입은 서브타입의 모든 값을 표현할 수 있으며 서브타입은 슈퍼타입의 일부 값을 표현하는 비교적 구체적인 타입이라고 할 수 있습니다.
타입스크립트 코드를 통해 슈퍼타입과 서브타입에 대한 예시를 알아보겠습니다. 이때 B <: A
라는 것은 B가 A의 서브타입이라는 것을 의미합니다. (반대로 A >: B
는 A가 B의 슈퍼타입이라는 것을 의미합니다.)
// string <: any
// number <: number | string
// 10 <: number
// "apple" <: string
// Dog <: Animal
interface Animal {
name: string;
}
interface Dog extends Animal {
bark: () => void;
}
let dog: Dog = {
name: "foo",
bark: () => console.log("!"),
};
let animal: Animal = {
name: "bar",
};
dog = animal; // Error
animal = dog;
위의 코드를 보면 슈퍼타입인 Animal을 서브타입인 Dog에 할당할 때 에러가 발생합니다. Animal 타입에는 bark라는 함수가 존재하지 않기 때문입니다. 하지만 서브타입인 Dog을 슈퍼타입인 Animal에 할당할 수 있습니다. Dog에도 Animal과 마찬가지로 name 속성이 존재하기 때문입니다. 이를 통해 슈퍼타입을 갖는 모든 변수에 상대적으로 구체적인 타입인 서브타입을 갖는 변수를 할당할 수 있다는 것을 알 수 있습니다.
가변성(Variance)은 어떤 조건에서 변할 수 있는 성질을 의미한다. 이번에는 가변성의 4가지 종류(공변, 반변, 양변, 불변) 중에서 공변과 반변에 대해 알아보겠습니다.
슈퍼타입과 서브타입에서 봤던 Animal과 Dog 타입을 이어서 보겠습니다. Animal 타입은 Dog 타입의 슈퍼타입입니다. 이때 Dog 타입을 갖는 변수는 Animal 타입을 갖는 변수에 할당이 가능했지만 그 반대는 불가능했습니다. 그리고 아래 코드에서 Animal과 Dog 타입이 각각 Animal[], Dog[] 타입이 되어도 상속관계는 변함이 없습니다. 이처럼 조건 또는 특성이 변경됐을 때 상속관계가 유지되는 것을 공변이라고 합니다.
// Dog <: Animal
interface Animal {
name: string;
}
interface Dog extends Animal {
bark: () => void;
}
let animalList: Array<Animal> = [];
let dogList: Array<Dog> = [];
animalList = dogList;
// dogList = animalList; // Error
하지만 Animal, Dog 타입이 함수의 파라미터가 되었을 때는 상속관계가 달라집니다. AnimalHandler 타입의 함수에 DogHandler 타입의 함수를 할당하면 할당할 수 없다는 에러가 발생합니다. 반면에 DogHandler 타입의 함수에 AnimalHandler 타입의 함수를 할당할 수 있습니다.
interface Animal {
name: string;
}
interface Dog extends Animal {
bark: () => void;
}
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;
const handleAnimal: AnimalHandler = (dog: Dog) => console.log(dog); // Error
const handleDog: DogHandler = (animal: Animal) => console.log(animal);
위의 함수 파라미터처럼 Animal과 Dog 타입의 상속관계가 해당 타입을 파라미터로 갖는 함수가 되면서 반대의 상속관계가 되는 것을 반변이라고 합니다. (아래 그림처럼 반대의 상속관계가 형성되었습니다.)
이처럼 타입스크립트에서 함수의 파라미터가 반변성을 갖는 이유는 타입 안전성을 보장하기 위함입니다. 함수 타입 간의 관계에서 파라미터 타입을 반변성을 갖도록 하여 함수 호출 시 발생할 수 있는 타입 오류를 런타임 이전에 발견하여 방지할 수 있습니다.
위에서 봤던 Dog, Animal 타입을 통해 함수의 파라미터가 공변성일 때 발생할 수 있는 문제를 조금 더 구체적으로 알아보겠습니다. 먼저 Animal 타입을 파라미터로 받는 함수의 타입인 AnimalHandler가 있습니다. 이때handleAnimal 함수는 AnimalHandler 타입으로 명시했지만 Dog 타입을 파라미터로 받는 함수를 정의하고 Dog 타입의 bark 함수를 호출하도록 했습니다.
이 상태에서 handleAnimal 함수를 호출할 때 Animal 타입을 갖는 값을 파라미터로 전달해도 런타임 이전에 오류가 표시되지 않습니다. 왜냐하면 handleAnimal은 Animal 타입이 파라미터인 함수이기 때문입니다. 하지만 런타임 시 animal은 bark함수를 호출할 수 없기 때문에 에러가 발생합니다.
// Dog <: Animal
interface Animal {
name: string;
}
interface Dog extends Animal {
bark: () => void;
}
type AnimalHandler = (animal: Animal) => void;
const animal: Animal = {
name: "bar",
};
const handleAnimal: AnimalHandler = (dog: Dog) => {
dog.bark();
};
handleAnimal(animal);
위와 같은 문제가 발생하지 않도록 함수 파라미터에 대해 반변성을 갖도록 하여 타입 안전성을 보장합니다. 이때 타입스크립트 설정에서 strictFunctionTypes 을 true로 설정해야 handleAnimal에 Dog 타입을 파라미터로 갖는 함수 타입을 정의할 때 오류가 표시될 것입니다. (strict 를 true로 설정했을 때도 마찬가지입니다.)
strictFunctionTypes
또는 strict
를 true로 설정해야 합니다.