분산성 — 공변·반변·불변과 in/out 어노테이션
TypeScript의 공변(Covariance), 반변(Contravariance), 불변(Invariance) 개념, 함수 타입에서의 적용, TypeScript 4.7 in/out 분산성 어노테이션을 완전히 정리합니다.
지난 글에서 NoInfer<T>로 추론 사이트를 제어하는 방법을 배웠다. 이번에는 TypeScript 타입 시스템의 근간 중 하나인 분산성(Variance)을 다룬다. 제네릭 타입이 서브타입 관계를 어떻게 계승하는지, 그리고 TypeScript 4.7에서 추가된 in/out 어노테이션으로 이를 명시적으로 선언하는 방법을 살펴본다.
분산성이란?
Dog extends Animal이 참일 때, Box<Dog> extends Box<Animal>도 참인가? 그 답이 곧 Box<T>의 분산성이다.
- 공변(Covariant): 서브타입 관계가 같은 방향으로 유지.
Box<Dog> extends Box<Animal>✓ - 반변(Contravariant): 서브타입 관계가 역전.
Box<Animal> extends Box<Dog>✓ - 불변(Invariant): 서브타입 관계 없음. 정확히 같은 타입만 호환.
- 이중변성(Bivariant): 양방향 모두 호환 (타입 안전성 완화).
class Animal { breathe() {} }
class Dog extends Animal { bark() {} }
// 공변: 읽기 전용 컨테이너
type ReadonlyBox<T> = { readonly value: T };
const box: ReadonlyBox<Animal> = { value: new Dog() }; // ✓ Dog ⊂ Animal
// 불변: 읽기+쓰기 컨테이너
type MutableBox<T> = { value: T };
// const mbox: MutableBox<Animal> = { value: new Dog() }; // ✗ 위험!
// 나중에 mbox.value = new Cat(); 가 가능해지므로
함수 타입의 분산성
함수 타입은 반환 타입은 공변, 매개변수 타입은 반변이 안전하다.
type GetAnimal = () => Animal;
type GetDog = () => Dog;
// 반환 타입 공변: Dog를 반환하는 함수는 Animal을 반환하는 위치에 사용 가능
const getAnimal: GetAnimal = () => new Dog(); // ✓
type EatAnimal = (a: Animal) => void;
type EatDog = (d: Dog) => void;
// 매개변수 반변: Animal을 받는 함수는 Dog를 받는 위치에 사용 가능
// (Animal 소비자는 Dog도 소비 가능 — Dog는 Animal의 모든 것을 가짐)
const eatDog: EatDog = (d: Animal) => { d.breathe(); }; // ✓
왜 매개변수가 반변이어야 안전한지는 리스코프 치환 원칙으로 이해할 수 있다. EatDog 함수가 기대하는 위치에 EatAnimal을 사용하면, Animal을 받는 함수에 Dog를 넘기는 것이므로 항상 안전하다.
TypeScript의 기본 동작
TypeScript는 기본적으로 함수 매개변수를 이중변성(bivariant)으로 처리한다. 이는 역사적 이유에서 비롯된 설계로, 메서드 선언(method(): void)은 여전히 이중변성이다.
interface Comparator<T> {
compare(a: T, b: T): number; // 메서드 선언 → 이중변성
}
type CompareFn<T> = (a: T, b: T) => number; // 함수 타입 → strictFunctionTypes 시 반변
strict: true 또는 strictFunctionTypes: true를 활성화하면 함수 타입 속성은 매개변수 반변이 적용된다.
TypeScript 4.7: in/out 분산성 어노테이션
TypeScript 4.7부터 타입 파라미터에 in, out 키워드로 분산성을 명시할 수 있다.
// out: 공변 — T는 출력(반환) 위치에만 사용
interface Provider<out T> {
get(): T;
}
// in: 반변 — T는 입력(매개변수) 위치에만 사용
interface Acceptor<in T> {
accept(value: T): void;
}
// in out: 불변 — 읽기+쓰기 모두
interface Transformer<in out T> {
transform(input: T): T;
}
어노테이션의 이점은 두 가지다.
- 명시적 의도: 해당 타입이 어떤 방식으로 T를 사용하는지 코드에서 드러난다.
- 성능 최적화: 컴파일러가 분산성을 직접 계산하지 않고 어노테이션을 신뢰하므로 대형 타입 체계에서 빠르다.
어노테이션이 실제 사용 패턴과 맞지 않으면 컴파일 오류가 발생한다.
interface Bad<out T> {
// ✗ 오류: out 선언했는데 T가 입력 위치에 사용됨
// set(value: T): void;
}
공변/반변 규칙이 실전에서 중요한 이유
// 콜백 배열 예시
type Handler<T> = (event: T) => void;
const mouseHandlers: Handler<MouseEvent>[] = [];
const eventHandlers: Handler<Event>[] = mouseHandlers;
// ✗ 위험: mouseHandlers를 eventHandlers로 쓰면
// eventHandlers.push((e: Event) => ...)가 가능해짐
// 실제로는 MouseEvent를 기대하는 핸들러가 Event를 받게 됨
// ReadonlyArray는 공변
const dogs: readonly Dog[] = [new Dog()];
const animals: readonly Animal[] = dogs; // ✓ 안전 (읽기만 가능)
분산성과 유니언
유니언에 분산되는(distributive) 조건부 타입과는 다른 개념이지만, 유니언 타입도 분산성과 연결된다.
// 공변 위치: 유니언이 확장될 수 있음
type F<T> = () => T;
type FDogOrCat = F<Dog | Cat>;
// FDogOrCat = () => Dog | () => Cat 으로 분해되지 않음
// F<Dog | Cat> = () => Dog | Cat 자체
// 반변 위치: 유니언이 교차로 변환
type G<T> = (x: T) => void;
// G<Dog | Cat>는 (x: Dog | Cat) => void
// Dog를 받는 G에 넣으려면 더 넓은 타입 필요 → 반변
핵심 정리
분산성은 제네릭 타입의 서브타입 관계 계승 방향을 결정한다. 공변(out 위치)은 서브타입 방향 유지, 반변(in 위치)은 역전, 불변(in+out)은 호환 없음이다. 함수의 반환 타입은 공변, 매개변수는 반변이 안전하다. TypeScript 4.7의 in/out 어노테이션으로 분산성을 명시해 의도를 명확히 하고 컴파일 성능을 높일 수 있다.
지난 글: NoInfer 유틸리티 — 제네릭 추론 사이트 제어
다음 글: 매핑된 타입 수정자 — +/- readonly와 optional 제어
읽어주셔서 감사합니다. 😊