타입스크립트의 제네릭을 사용하다 보면 아래와 같은 에러를 마주치게 될 때가 있다.
'A' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'A'.
A가 T에 할당 가능하긴 한데 T가 A와 다른 서브타입으로 인스턴스화 될 수 있다고?
문제 상황 1.
Animal을 확장하는 어떤 타입 T가 있을 때, 이 타입에 해당하는 객체 배열을 인자로 받아서 무작위로 하나를 골라주는 getRandomAnimal
함수를 작성했다. animals 인자가 넘어오지 않은 경우에도 Animal 객체를 반환해 줄 수 있도록, animals
파라미터의 기본값으로 defaultAnimal
객체가 들어 있는 배열을 설정해 두었다.
1type Animal = { name: string };23const defaultAnimal: Animal = { name: 'Bori' };45const getRandomAnimal = <T extends Animal>(6 animals: T[] = [defaultAnimal]7): T => {8 const randomIndex = Math.floor(Math.random() * animals.length);9 return animals[randomIndex];10}
그런데 animals의 기본값을 설정하면 아래와 같은 에러가 발생한다.
1Type 'Animal' is not assignable to type 'T'.2 'Animal' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Animal'.
문제 상황 2.
이번에는 파라미터 기본값을 설정하지 않았다. 대신, animals가 빈 배열이라면 Animal 타입의 defaultAnimal을 반환하게 만들었다.
1const defaultAnimal: Animal = { name: 'Bori' };23const getRandomAnimal = <T extends Animal>(4 animals: T[] = []5 ): T => {6 if (animals.length === 0) {7 return defaultAnimal; // Error! Type 'Animal' is not assignable to type 'T'.8 }9 const randomIndex = Math.floor(Math.random() * animals.length);10 return animals[randomIndex];11}
이번에는 return defaultAnimal
부분에서 똑같은 에러가 발생한다.
다시, 발생한 에러를 살펴보자.
1Type 'Animal' is not assignable to type 'T'.2 'Animal' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Animal'.
에러를 읽어보면 아래와 같은 내용이다.
- 타입
Animal
은T
에 할당 가능하지 않다. Animal
은T
타입의 제약 조건에 할당 가능하지만,T
는Animal
의 제약 조건을 따르는 다른 하위타입으로 인스턴스화 될 수 있다.
T
가 Animal
을 확장하는데 Animal
이 T
에 할당 가능하지 않다니?
타입스크립트가 막고자 하는 것
getRandomAnimal
은 제네릭 함수여서, 함수를 호출하는 시점에 제네릭 T의 타입을 명시해 줄 수 있다.
1const animal = getRandomAnimal<Animal>();
이 경우, 인자를 넘기지 않았기 때문에 getRandomAnimal
은 기본값에 설정된 defaultAnimal
을 반환할 것이다. defaultAnimal의 타입은 Animal 타입이고, animal 변수의 타입도 Animal이므로 문제 없이 동작한다.
이번에는 Animal의 하위 타입인 Bird 타입이 있다고 해 보자. 그리고 Bird 타입에 대해서 getRandomAnimal
함수를 호출하는 상황을 생각해 보자.
1type Bird = Animal & { fly: () => void };23const bird = getRandomAnimal<Bird>();4 // ^? const bird: Bird56bird.fly(); // Error - Uncaught TypeError: bird.fly is not a function
Bird
타입의 객체는 fly
메서드를 가진다. 그리고 getRandomAnimal
을 호출할 때 제네릭으로 Bird 타입을 지정했기 때문에, getRandomAnimal<Bird>
의 반환 타입은 Bird가 되어야 한다.
인자를 넘기지 않았기 때문에 getRandomAnimal
은 defaultAnimal
객체를 반환하며, 이 객체는 fly 메서드가 없다. 따라서 bird 타입인 줄 알고 fly 메서드를 호출하려고 하면 에러가 발생하게 된다.
아까 발생한 에러를 다시 생각해 보자.
- Animal은 T 타입의 제약 조건에 할당 가능하다. 여기서 제약 조건은
T extends Animal
인데,Animal extends Animal
은 참이기 때문이다. - 그러나
T
는Animal
의 다른 하위 타입으로 인스턴스화 될 수 있다. 위의Bird
타입처럼,T
가Animal
의 다른 타입으로 확장될 경우, 타입 호환성을 보장할 수 없다.
따라서, Animal은 T에 할당 가능하지 않다.
해결 방법
이런 문제를 해결하려면, 기본값을 설정하지 않거나, 위험을 무릅쓰고 defaultAnimal
의 타입을 T로 단언해 줄 수 있다.
1// 1. 기본값을 주지 않는다2const getRandomAnimal = <T extends Animal>(3 animals: T[]4): T => {5 const randomIndex = Math.floor(Math.random() * animals.length);6 return animals[randomIndex];7}89// 2-1. 위험을 무릅쓰고 defaultAnimal의 타입을 T로 단언해준다10const getRandomAnimal = <T extends Animal>(11 animals: T[] = [defaultAnimal as T]12): T => {13 const randomIndex = Math.floor(Math.random() * animals.length);14 return animals[randomIndex];15}1617// 2-2. 위험을 무릅쓰고 defaultAnimal의 타입을 T로 단언해준다18const getRandomAnimal = <T extends Animal>(19 animals: T[] = []20 ): T => {21 if (animals.length === 0) {22 return defaultAnimal as T;23 }24 const randomIndex = Math.floor(Math.random() * animals.length);25 return animals[randomIndex];26}
혹은, defaultAnimal을 T 타입으로 변환해주는 함수를 인자로 받게 할 수도 있다. 가장 확실한 방법이긴 하지만, 아래 코드처럼 convertAnimal
과 같은 함수를 따로 구현해야 한다는 단점이 있다.
이 경우, defaultAnimal이 필요하지 않은 경우에도 convertAnimal을 구현해야 할 수도 있다는 단점이 있다. 또, Bird 타입의 경우에는 fly 메서드가 추가로 필요한데, defaultAnimal에서는 어떻게 구현해 주어야 할 것인가를 고민해야 한다.
1const getRandomAnimal = <T extends Animal>(2 animals: T[] = [],3 // 외부에서 Animal을 T 타입으로 변환하는 함수를 넘겨준다4 convertAnimal: (animal: Animal) => T5 ): T => {6 const randomIndex = Math.floor(Math.random() * animals.length);7 if (randomIndex > -1) {8 return animals[randomIndex];9 }10 // defaultAnimal을 T 타입으로 변환해서 안전하게 사용한다11 return convertAnimal(defaultAnimal);12}1314getRandomAnimal<Bird>(15 [],16 (animal: Animal) => ({ ...animal, fly: () => {} })17);
결론적으로, 제네릭 타입이 사용되는 함수에서는 제네릭 타입이 올 수 있는 곳에 제네릭의 하위 타입이 될 수 없는 값을 사용하지 않는 것이 가장 안전하다.