배경
Apollo Client 오픈소스 코드 중 MockedResponse에서 in, out이라는 TypeScript 키워드를 처음 보게 되었다. TypeScript를 많이 사용했음에도 불구하고 이 키워드는 처음보는 거라 궁금해서 공부하게 되었다.
export interface MockedResponse<
// @ts-ignore
out TData = Record<string, any>,
out TVariables = Record<string, any>,
> {
request: GraphQLRequest<TVariables>;
maxUsageCount?: number;
result?:
| FetchResult<Unmasked<TData>>
| ResultFunction<FetchResult<Unmasked<TData>>, TVariables>;
error?: Error;
delay?: number;
variableMatcher?: VariableMatcher<TVariables>;
newData?: ResultFunction<FetchResult<Unmasked<TData>>, TVariables>;
}
TypeScript
복사
TypeScript의 제네릭에서 in, out 키워드는 타입의 변성(variance)을 나타낸다.
변성이란?
타입의 변성(variance)은 타입 계층에서 제네릭 타입이 어떻게 변화할 수 있는지를 설명하는 개념이다. TypeScript는 기본적으로 변성을 자동으로 처리하지만, 변성 어노테이션(in, out)을 사용하면 제네릭 타입의 의도를 명확히 드러낼 수 있다.
변성의 종류
1.
공변성 (Covariance) → out 키워드 사용
2.
반공변성 (Contravariance) → in 키워드 사용
3.
무공변성 (Invariant) → in out 키워드 사용
공변성(out)이란?
공변성은 타입의 생산자(producer) 역할을 하는 경우 사용된다.
즉, 제네릭 타입이 리턴값에 사용될 때 적용된다.
ex) A가 B 타입을 포함하면, T<A> 자리에 T<B>를 사용할 수 있음
예시 코드
interface Producer<out T> {
produce(): T;
}
const stringProducer: Producer<string> = {
produce: () => "Hello",
};
const genericProducer: Producer<string | number> = stringProducer; // ✅ 가능 (공변적 관계)
TypeScript
복사
위 코드에서 Producer<string>을 Producer<string | number>에 할당할 수 있다. string은 string | number의 부분 집합이기 때문이다.
반공변성(in)이란?
반공변성은 타입의 소비자(consumer) 역할을 하는 경우 사용된다.
즉, 제네릭 타입이 함수의 인자로 사용될 때 적용된다.
ex) A가 B타입을 포함하면, T<B> 자리에 T<A>를 사용할 수 있음
예시 코드
interface Consumer<in T> {
consume(arg: T): void;
}
const logNumber = (arg: number) => console.log(arg);
const logNumberOrString: Consumer<number | string> = { consume: logNumber };
const log: Consumer<number> = logNumberOrString; // ✅ 가능 (반공변적 관계)
// const log2: Consumer<number | string> = log; // ❌ 불가능
TypeScript
복사
위 코드에서 Consumer<number | string>을 Consumer<number>에 할당할 수 있다.
•
logNumberOrString은 number | string을 처리할 수 있으므로 number를 받아야 하는 곳에서 사용될 수 있다.
•
하지만 반대로 logNumber는 string을 처리할 수 없으므로 Consumer<number>를 Consumer<number | string>에 할당할 수 없다.
무공변성(in out)이란?
때로는 제네릭 타입이 함수의 입력값(consumer)과 출력값(producer) 모두에서 사용될 때가 있다. 이 경우, in out 어노테이션을 사용할 수 있다.
예시 코드
interface ProducerConsumer<in out T> {
consume: (arg: T) => void;
make(): T;
}
TypeScript
복사
이 인터페이스에서는 T가 consume의 파라미터 타입으로 사용되면서 동시에 make()의 반환 타입으로 사용된다. 따라서 T는 무공변적(invariant)이다.
변성 어노테이션을 사용하는 이유
1.
타입 의도를 명확히 표현할 수 있다. (예: 리턴값이면 out, 파라미터면 in)
2.
타입 안정성을 높일 수 있다. (예: 잘못된 타입 변성을 막음)
사용 시 주의할 점
•
타입이 구조적으로 일치할 때만 사용해야 한다.
•
예를 들어 out T라고 지정한 후, string | number를 제네릭으로 사용하고 number를 반환하면 string | number !== number가 되어 구조적으로 일치하지 않으므로 변성 어노테이션이 무시될 수 있다.