타입스크립트의 공변성, 반공변성, 초과 속성 검사

박세환·2025년 1월 18일

타입스크립트에서 타입 간의 호환성을 다루는 개념인 공변성(covariant)과 반공변성(contravariant)이 적용되는 경우를 정리하고자 한다.
여기에 더해, 불변성(invariant)과 초과 속성 검사(Excess Property Check)도 같이 정리하겠다.

공변성(covariant)

타입 A와 A의 서브타입 B가 있다고 하자.
A가 더 일반적인, 포괄적인 타입이므로 다음과 같이 예시를 들 수 있겠다.

  • A -> string | number, B -> string
  • A -> {age: 1}, B -> {age: 1, name: 'john'}

A를 사용하는 모든 곳에서 B를 사용할 수 있다면 공변적이라고 할 수 있다.
다시 말해, 공변적이라는 것은 일반적인 타입이 구체적인 타입으로 대체될 수 있다는 의미이다.

타입스크립트는 기본적으로 공변성을 가질 때 타입 호환이 된다. 예를 들어:

class Animal {}
class Dog extends Animal {}

const animals: Animal[] = [new Animal()];
const dogs: Dog[] = [new Dog()];

const moreAnimals: Animal[] = dogs;

Dog[] 타입은 Animal[]의 서브 타입이고, 서브 타입은 수퍼 타입에 할당될 수 있다. 공변성을 따르기 때문이다.

서로 다른 두 함수 시그니처에서 반환 타입이 포함 관계에 있을 때, 함수 할당 시 공변성을 따르는 예시이다:

type Foo = () => string
type Bar = () => string | number

const f: Foo = () => 'this is foo'

const b: Bar = f

위 예시를 직관적으로 이해해보자.
변수 bBar 타입을 명시함으로써, string이나 number를 반환하는 함수여야 한다는 것을 나타냈고, 여기에 string을 반환하는 함수인 f를 할당했다.
string이나 number를 반환하는 함수여야 하는데, string을 반환하는 함수를 할당했으니 아무런 문제가 없다. 공변성을 따라도 괜찮다!

공변성은 타입이 Output으로서 사용되었을 때에만 적용된다.

반공변성(contravariant)

반공변성은 공변성의 반대다.
구체적인 타입이 일반적인 타입으로 대체될 수 있다는 의미가 된다.

타입스크립트에서는 함수의 매개변수가 반공변성을 따른다.

type SpecificHandler = (input: { a: string; b: string }) => void;
type GeneralHandler = (input: { a: string }) => void;

const handler: GeneralHandler = (input) => console.log(input.a);

const specificHandler: SpecificHandler = handler;

매개변수가 더 일반적인 handler 함수를 구체적인 매개변수를 요구하는 변수에 할당했다.
구체적인 타입을 일반적인 타입에 할당하는 경우와 반대이므로 반공변성이라고 할 수 있다.

이 또한 직관적으로 이해해보자.
GeneralHandler 타입을 갖는 handler 함수는 매개변수로 { a: string }만을 필요로 한다. a만 있다면, 다른 속성이 존재해도 아무 상관이 없다.
SpecificHandler 타입을 갖는 specificHandler 함수는 매개변수로 { a: string; b: string }를 가질 것이다. 실제 값에 해당하는 handler 함수는 { a: string }만 있으면 되기 때문에, { a: string; b: string }를 전달받아도 문제될 것이 없다. b는 안 쓰면 되니까.

반대로, GeneralHandlerSpecificHandler로 대체한다고 가정해보자.
실제로는 a속성과 b속성이 필요한 함수이지만, GeneralHandler 타입 정의에 따르면 매개변수로 a 속성만 넘기면 된다고 속이는 꼴이다.

type SpecificHandler = (input: { a: string; b: string }) => void;
type GeneralHandler = (input: { a: string }) => void;

const handler: SpecificHandler = (input) => console.log(input.a, input.b);

const generalHandler: GeneralHandler = handler;

따라서, 함수의 매개변수는 반공변성을 따라야 한다.

반공변성은 타입이 Input으로서 사용되었을 때에만 적용된다.

불변성(Invariant)

공변성을 따르지도, 반공변성을 따르지도 않는 경우에 불변성을 따른다고 한다.
Typescript와 Java의 경우가 약간 다르다.

앞서 제시된 공변성을 따르는 타입스크립트 예시를 Java로 바꿔 생각해보자.

List<Animal> animals = new ArrayList<Cat>(); // Compile Error
animals.add(new Dog());

결론부터 말하면, Java는 불변성을 따르게 한다.
공변성을 허용하게 되면, Cat 리스트에 Dog를 추가할 수 있게 되는데, Java는 이런 런타임 타입 오류도 막고자 하기 때문이다.

class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}

const animals: Animal[] = [new Animal()];
const dogs: Dog[] = [new Dog()];

const moreAnimals: Animal[] = dogs;

moreAnimals.push(new Cat()) // ok

공변성을 따르는 타입스크립트는 다르다. 런타임 시에 dogs 배열에 Cat을 추가할 수 있게 되는 것까지 막지 않는다. (moreAnimals는 Dog 배열이 아닌 Animal 배열로 간주되므로 문제가 없다고 하는 것이 정확한 표현 같다.)

초과 속성 검사(Excess Property Check)

객체 리터럴을 사용하여 새로운 객체를 생성하는 경우, 대상 타입에 명시되지 않은 속성이 추가로 존재하거나 생략된 경우에 타입 에러를 던진다.
객체 속성이 정확히 동일해야 할당이 허용되므로 불변성을 띄는 것처럼 보이지만, 이는 초과 속성 검사에 의한 것이다.

interface Person {
  name: string;
  age: number;
}

// Error: Object literal may only specify known properties, and 'job' does not exist in type 'Person'
const person: Person = { name: "Alice", age: 25, job: "SW developer" };

위 예시에서는 Person의 서브 타입을 갖는 객체 리터럴이 person에 할당되는 경우이므로 공변성을 따르지만, 초과 속성 검사에 의해 허용되지 않고 있다.

초과 속성 검사는 객체 리터럴을 바로 할당하지 않으면 수행되지 않는다.

interface Person {
  name: string;
  age: number;
}
type WorkingPerson = {
    job: string
} & Person

const workingPerson: WorkingPerson = { name: "Alice", age: 25, job: 'SW Developer' };

const person: Person = workingPerson

코드 실행 동작은 완전히 동일하다.

참고 자료

profile
And I'm ready to dive

0개의 댓글