정의
변성(variance) 은 타입끼리의 상하 관계가 껍데기를 씌운 뒤에도 유지되는지를 정하는 규칙이에요.
Dog가 Animal의 일종이라고 해볼게요. 그럼 Dog[]도 Animal[]의 일종일까요? Dog를 받는 함수는 Animal을 받는 함수의 일종일까요? 답이 자리마다 다른데, 그 답을 정해 주는 게 변성이에요.
네 가지 경우가 있어요.
- 공변(covariant) — 관계가 그대로 유지돼요.
Dog[]를Animal[]자리에 넣을 수 있어요. - 반공변(contravariant) — 관계가 뒤집혀요.
Animal을 받는 함수를Dog를 받는 함수 자리에 넣을 수 있어요. - 이변(bivariant) — 양쪽 다 허용해요. 편하지만 안전하지 않아요.
- 불변(invariant) — 정확히 같은 타입만 허용해요.
왜 필요한가
변성 규칙이 없으면, 타입 검사를 멀쩡히 통과한 코드가 런타임에 터져요. 제일 유명한 예가 배열이에요.
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // 통과해요
animals.push(new Cat()); // 통과해요
dogs[1].bark(); // 💥 Cat에는 bark가 없어요
dogs와 animals는 같은 배열을 가리켜요. 그래서 animals에 고양이를 넣으면 dogs 안에도 고양이가 들어가요. 그런데 타입상 dogs는 개만 들어 있는 배열이라, bark()를 부르는 코드가 통과해 버려요.
핵심은 이거예요. 읽기만 하면 안전하고, 쓰기까지 허용하면 안전하지 않아요. 그래서 변성은 "이 자리가 읽는 자리인지 쓰는 자리인지"에 따라 허용 방향을 다르게 정해요.
실무에서 이걸 처음 만나는 순간은 대개 콜백을 받는 컴포넌트를 만들 때예요. onSelect: (row: Row) => void 같은 prop이 어떤 함수까지 받아들여야 하는지, 그리고 컴파일러가 그걸 얼마나 엄격하게 검사하는지가 API 안전성을 좌우해요.
동작 원리
반공변이 왜 뒤집히는지
함수 파라미터 자리가 반공변인 이유는 생각보다 직관적이에요. 더 넓게 받는 함수는 좁은 자리에 넣어도 안전하니까요.
type DogHandler = (x: Dog) => void;
const handleAnyAnimal = (x: Animal) => console.log(x.name);
const handleOnlyDog = (x: Dog) => x.bark();
const h1: DogHandler = handleAnyAnimal; // ✅ 개를 넘겨도 문제없어요
const h2: (x: Animal) => void = handleOnlyDog; // ❌ 고양이를 넘기면 bark()가 없어요
handleAnyAnimal은 모든 동물을 처리할 수 있으니, 개만 오는 자리에 놓아도 아무 문제가 없어요. 반대로 handleOnlyDog를 아무 동물이나 오는 자리에 놓으면, 고양이가 들어왔을 때 터져요.
즉 파라미터는 넓을수록 안전하고, 반환값은 좁을수록 안전해요. 그래서 반환 타입은 공변, 파라미터 타입은 반공변이에요.
자리별 변성 정리
| 자리 | 변성 | 이유 |
|---|---|---|
| 함수 반환 타입 | 공변 | 받는 쪽은 읽기만 해요 |
| 함수 파라미터 타입 | 반공변 | 넘기는 쪽은 쓰기만 해요 |
readonly 속성 / readonly T[] | 공변 | 읽기만 가능하니 안전해요 |
읽고 쓰는 속성 / T[] | 이론상 불변 | TypeScript는 편의상 공변으로 처리해요 |
| 메서드 축약 문법의 파라미터 | 이변 | 편의를 위한 의도적 예외예요 |
배열이 이론상 불변인데 TypeScript가 공변으로 처리한다는 게 위에서 본 사고의 원인이에요. 이건 버그가 아니라 의도적으로 받아들인 불건전함이에요. 배열을 엄격하게 검사하면 실용적인 코드 대부분이 막히거든요.
strictFunctionTypes가 못 막는 자리
strictFunctionTypes 옵션을 켜면 함수 타입의 파라미터를 반공변으로 엄격히 검사해요. 그런데 메서드 축약 문법은 이 옵션의 적용 대상이 아니에요.
interface WithMethod {
on(cb: (e: Dog) => void): void; // 메서드 축약 → 이변, 느슨해요
}
interface WithProperty {
on: (cb: (e: Dog) => void) => void; // 함수 프로퍼티 → 반공변, 엄격해요
}
두 선언은 읽기에 거의 같아 보이는데 검사 강도가 달라요. 문법 한 글자 차이로 보호가 켜졌다 꺼졌다 하는 거예요.
이 예외가 존재하는 이유는 Array.prototype.push처럼 표준 라이브러리 메서드 상당수가 엄격한 검사를 통과하지 못하기 때문이에요. 호환성을 위해 남겨둔 구멍이에요.
실무 적용
읽기 전용으로 공변성 구멍 막기
가장 값싸고 효과 좋은 방법이에요.
// 위험 — 받은 쪽이 원소를 추가할 수 있어요
function render(items: Item[]) { /* ... */ }
// 안전 — 쓰기 연산 자체가 타입에 없어요
function render(items: readonly Item[]) { /* ... */ }
readonly T[]에는 push도 splice도 없어요. 공변성이 불건전한 이유가 "쓰기"였으니, 쓰기를 없애면 문제가 사라져요.
콜백은 함수 프로퍼티로 받기
// 느슨함 — 잘못된 핸들러가 통과할 수 있어요
interface Props {
onSelect(row: Row): void;
}
// 엄격함 — 파라미터가 반공변으로 검사돼요
interface Props {
onSelect: (row: Row) => void;
}
공개 API에서 안전이 중요한 콜백은 함수 프로퍼티로 선언해요.
변성을 직접 선언하기
TypeScript 4.7부터 제네릭 파라미터에 in(반공변) / out(공변)을 붙여 의도를 고정할 수 있어요.
interface Producer<out T> { get(): T }
interface Consumer<in T> { set(value: T): void }
interface Store<in out T> { get(): T; set(value: T): void }
추론을 대체하는 게 아니라 의도를 못 박고 검증받는 용도예요. 잘못 붙이면 컴파일러가 "이 자리는 그 변성이 아니다"라고 알려줘요. 타입이 큰 라이브러리에서는 추론 비용을 줄이는 효과도 있어요.
변성에 기대지 않는 제약 고르기
타입 제약을 쓸 때, 그게 통과하는 이유가 "컴파일러가 이 필드를 반공변으로 판정하기 때문"이면 위험해요. 필드를 메서드 축약으로 바꾸는 순간 이변성 예외가 적용돼 근거가 무너지거든요.
// 변성 판정에 의존해요 — 문법이 바뀌면 조용히 무너져요
function f<T extends readonly ColumnDef<never>[]>(cols: T) {}
// 구조만 요구해요 — 문법과 무관하게 안정적이에요
function f<T extends readonly { readonly id: string }[]>(cols: T) {}
트레이드오프
엄격하게 갈수록 통과하는 코드가 줄어들어요. 모든 것을 불변으로 만들면 이론적으로는 완벽하지만, 실용적인 코드 대부분이 막혀서 아무도 안 써요. TypeScript가 배열을 공변으로 두고 메서드에 이변성 예외를 남긴 건 이 균형을 택한 결과예요.
readonly를 전면 도입하면 안전은 확실히 올라가는데, 기존 코드베이스에 넣으면 수정 지점이 꽤 넓게 퍼져요. 새로 만드는 공개 API부터 적용하고 점진적으로 넓히는 게 현실적이에요.
in/out 애너테이션은 의도를 명확히 하지만, 붙이는 순간 그 제네릭의 사용처가 제약돼요. 나중에 반환값에도 쓰고 싶어졌을 때 in이 걸림돌이 돼요. 오래 유지할 라이브러리 타입에만 쓰는 게 좋아요.
사용하면 안 되는 경우
- 앱 내부의 짧은 헬퍼 함수. 변성까지 따져서 설계할 필요가 없어요. 호출부가 몇 개고 전부 같은 파일에 있다면, 그냥 구체 타입으로 쓰는 게 읽기도 쉽고 안전해요.
- 변성만으로 런타임 안전을 보장하려는 시도. 변성은 컴파일 시점 규칙이에요. API 응답이나
localStorage값처럼 밖에서 들어온 데이터는 변성과 무관하게 런타임 파서로 검사해야 해요. - 이변성 예외를 우회하려고 타입을 복잡하게 만드는 것. 메서드 축약이 느슨한 게 문제라면, 답은 복잡한 제네릭이 아니라 함수 프로퍼티로 바꾸는 것이에요.
흔한 실수
Dog[]를Animal[]로 넘기고 받은 쪽에서 추가한다. 가장 고전적인 사고예요. 넘길 때readonly를 붙이면 애초에 일어나지 않아요.strictFunctionTypes를 켰으니 콜백이 안전하다고 믿는다. 메서드 축약으로 선언했다면 그 옵션은 적용되지 않아요. 선언 문법을 직접 확인해야 해요.- 변성 판정에 기대는 제약을 쓴다. 통과하는 근거가 컴파일러의 판정이면, 문법이 바뀔 때 에러 없이 보호만 사라져요.
in/out을 성능 목적으로만 아무 데나 붙인다. 애너테이션은 계약이에요. 나중에 그 제네릭을 다른 자리에 쓰려 할 때 막혀요.- 타입이 통과했으니 동작도 맞다고 보고한다. 변성은 안전 증명이 아니라 값싼 필터예요. TypeScript는 의도적으로 불건전한 부분을 갖고 있어요.
관련 개념
- derive-types-from-values — 값에서 타입을 파생할 때 변성에 기대지 않는 제약을 고르는 이유
- generic-context-factory — 제네릭 컨텍스트에 콜백을 실을 때 파라미터 위치의 검사 강도
- type-level-testing — 변성 판정이 실제로 오용을 막는지 컴파일러 증거로 확인하기