당신의 타입스크립트는 안녕하십니까

TypeScript는 이제 표준이 되었다
요즘 웹 개발에서 TypeScript는 JavaScript 기반 프로젝트의 표준으로 자리 잡았다고 해도 과언이 아니다. JavaScript 프로젝트가 점점 더 복잡한 구조를 갖게 되면서, 타입 시스템의 필요성이 대두되었고 여러 대안이 제시되었지만, 현재 TS는 이 분야에서 사실상 독점적인 위치를 차지하고 있다.
TypeScript 도입 초기에는 JavaScript의 자유로운 개발 방식을 제한한다는 우려와 낮은 숙련도로 인한 생산성 저하 문제가 종종 제기되었다. 하지만 시간이 흐르며 개발자들의 타입 시스템 활용 능력이 향상되었고, 결과적으로 보다 안정적인 개발 환경을 구축할 수 있게 되었다. 이제 런타임에서 발생하던 많은 에러는 IDE에서 개발 시점에 바로 발견되며, 이러한 변화는 개발자들로 하여금 TypeScript에 대한 신뢰를 높이는 계기가 되었다.
그러나 TypeScript는 JavaScript에 타입을 도입하기 위한 최선의 대안일지언정, 완벽한 시스템은 아니다. TypeScript에도 결함이 존재하며, 대부분의 개발자는 이를 인지하지 못한 채 TypeScript를 사용한다. 이로 인해 런타임에서 예기치 않은 에러가 발생할 때, 당황스러운 상황을 겪기도 한다.
지금 당신의 TypeScript는 정말로 안전한가
TypeScript를 사용하며 경험했던 결함 문제
TypeScript를 사용하며 경험한 문제를 공유해 보려 한다. 분명 IDE에서는 타입 에러를 감지하지 못했는데, 런타임에서 예상치 못한 에러가 발생했다. 필자는 React와 TypeScript를 함께 사용하며 개발하고 있는데, 간단한 예제를 통해 해당 상황을 설명하겠다.
const Button = ({ onClick }: { onClick: () => void }) => {
return <button onClick={onClick}>터져랏</button>;
};
export default function App() {
const onClick = (func?: () => void) => {
func?.();
};
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<Button onClick={onClick} />
</div>
);
}
onClick
함수는func
라는 옵셔널 인자를 받아,func
가 존재할 때만 실행하도록 작성되었다.Button
컴포넌트는onClick
이라는 함수를 props로 받아noop
형태의 타입(() => void
)을 요구한다.- 그러나
onClick
함수는func
라는 옵셔널 매개변수를 포함한 타입이다. 이 함수가Button
컴포넌트에 전달되며,noop
타입으로 치환되었고, TypeScript는 이를 타입 에러 없이 통과시켰다. - 결과적으로, 런타임에서 예상치 못한 에러가 발생했다.
런타임 에러는 다음과 같다:

해당 에러는 Sentry를 통해 알게 되었고, 코드 상에서 미처 발견하지 못했다. 어째서 TypeScript의 타입 시스템을 통과 했음에도 런타임 에러가 발생하게 되었을까 해당 현상을 이해하기 위해서는 타입 검사에 대한 이해가 필요했다. “슈퍼타입과 서브타입”, “서브타입 시스템”, “공변, 반공변, 이변성”에 대한 이해가 필요하다
슈퍼타입과 서브타입
타입 검사는 아이들이 모양 넣기 하듯이 어떤 타입에 어떤 타입을 넣을 수 있는지 여부에 따라 타입 에러를 내거나 타입 통과를 시킨다.

어떤 타입을 어떤 타입에 넣을 수 있는지 확인하기 위해서 슈퍼타입과 서브타입이라는 개념을 사용한다.
- 슈퍼타입: 공통된 개념과 속성을 정의하는 상위 개념.
- 서브타입: 슈퍼타입을 상속받아 구체적인 특성과 행동을 추가하는 하위 개념.
예를 들면
- 동물, 사람과의 관계에서는 동물은 슈퍼타입, 사람은 서브타입이다.
- 사람, 학생과의 관계에서는 사람은 슈퍼타입, 학생은 서브타입이다.
슈퍼타입과 서브타입의 관계는 상대적이며 존재의 특성의 바운더리에 따라서 달라진다.
(보통, 코드로 된 타입들을 이와 같은 기준으로 판단하기는 어렵긴 해서 필자는 한 타입이 다른 타입을 대체할 수 있으면 서브타입이라고 판단한다)
서브타입 시스템
타입은 결국 대상을 어떻게 바라보고, 특정 지을 것인지에 대한 문제다. 따라서 무엇이 서브타입이고, 무엇이 슈퍼타입인지 결정하는 방식도 언어의 설계에 따라 달라질 수 있다.
예를 들어, Java는 이름(Name)을 기반으로 타입을 분류한다.
이름 기반 서브타입 시스템이란?
서브타입 관계를 이름(명시적 선언)을 통해 정의하는 타입 시스템입니다. 서브타입은 슈퍼타입을 명시적으로 상속하거나 구현해야 하며, 이는 인터페이스와 클래스 상속 체계를 통해 구현됩니다.
Java는 클래스 기반 언어로 설계되었으며, 이러한 설계는 아리스토텔레스의 분류학과 유사한 점이 많다. 이름 기반 서브타입 시스템은 이를 이해하는 데 좋은 예시가 된다.
아리스토텔레스의 분류학으로 생각해보기
속성이 동일한 개체들
을 같은 범주에 속한다고 정의- 범주는 정의와 구별의 합으로 나타남이를 기반으로 아리스토텔레스는 현실 세계의 다양한 개체를 체계적으로 분류했고, 최초로 동물을 분류한 사람이 되었습니다. 예를 들어, 돌고래는 속성에 따라 어류가 아닌 포유류로 분류되었습니다.(참고: 자바스크립트는 왜 프로토타입을 선택했을까)
프로그래밍 관점에서 본다면, 돌고래는 어류 타입이 아닌 포유류 타입을 상속받는다. 따라서 돌고래를 어류 타입으로 처리하려고 하면 타입 에러가 발생할 것이다.
반면, TypeScript는 구조를 기반으로 타입을 분류한다.
구조 기반 서브타입 시스템이란?
객체나 타입의 관계를 이름이 아닌 구조적 유사성(프로퍼티나 메서드의 형태)을 기준으로 판단하는 타입 시스템입니다. TypeScript와 같은 언어에서 사용되며, 타입 간의 호환성을 판단할 때 선언된 이름이 아니라 구조적 속성을 기준으로 합니다.
TypeScript의 타입 설계는 JavaScript의 기반이 되는 로쉬의 프로토타입 이론에 기반을 두고 있다. 프로토타입 이론은 아리스토텔레스의 분류학과 달리, 대상을 특정 범주로 나누기보다는 특성들의 조합으로 본다. 따라서 타입 검사도 명명된 범주가 아니라 객체의 구조적 특성에 따라 이루어진다.
위의 돌고래 예시를 다시 보면, 구조 기반 타입 시스템에서는 돌고래가 포유류나 어류로 분류되지 않는다. 대신, '헤엄을 칠 수 있다'는 특성과 '모유를 먹일 수 있다'는 특성을 조합하여 정의된 타입으로 취급된다. 즉, 특정 범주에 속한 존재가 아니라, 특성들의 집합으로 이해된다.
공변성, 반공변성, 이변성
마지막으로 타입의 3가지 관계를 알아야 보자
공변성(Covariance)은 서브타입이 슈퍼타입을 대신할 수 있는 경우를 말한다.
type Mammal = {
breastFeed: () => void;
};
type Dolphin = {
swim: () => void;
breastFeed: () => void;
};
const dolphin: Dolphin = {
breastFeed: () => {},
swim: () => {},
};
const mammal: Mammal = dolphin; // 서브타입이 슈퍼타입을 대체, 공변
const mammalFeed = (mammalParam: Mammal) => mammalParam.breastFeed();
mammalFeed(dolphin); // 서브타입이 슈퍼타입을 대체, 공변
공변성은 일반적으로 변수에 값을 할당을 할 때 볼 수 있다
반공변성(Contravariance)은 슈퍼타입이 서브타입을 대신할 수 있는 경우를 말한다.
type Mammal = {
breastFeed: () => void;
};
type Dolphin = {
swim: () => void;
breastFeed: () => void;
};
const dolphin: Dolphin = {
breastFeed: () => {},
swim: () => {},
};
const mammalFeed = (mammalParam: Mammal) => mammal.breastFeed();
const dolphinFeed = (dolpinFeedCallback: (dolphinParam: Dolphin) => void) => {
dolpinFeedCallback(dolphin);
};
dolphinFeed(mammalFeed);
// 함수의 매개변수의 관계에서는 슈퍼타입이 서브타입을 대신한다, 반공변성
반공변성은 함수의 매개변수의 사이에서 보통 볼 수 있는 현상이다. mammalFeed
의 mammalParam
이라는 매개변수가 func
의 dolphinParam
을 대신 했다. (tsconfig
의 strictFunctionTypes
옵션을 false
로 해두면 TypeScript의 함수 매개변수 검사에서도 아래와 같은 이변성을 만날 수 있습니다)
추가로 단순히 함수끼리의 관계로만 보면 dolphinFeedCallback
을 mammalFeed
가 대신했기 때문에 mammalFeed
는 dolphinFeedCallback
의 서브타입이다 (공변성)
재밌는 점은 타입스크립트는 매개변수의 숫자로도 슈퍼타입, 서브타입을 판단한다.
// 변수 개수가 작은 함수를 변수 개수가 많은 함수에 할당 시 타입 통과
// 변수 개수가 많은 함수를 변수 개수가 작은 함수에 할당 시 타입 에러
// 변수 개수가 작은 함수가 서브타입, 변수 개수가 많은 함수가 슈퍼타입
type Type1 = (a: string, b: number) => void;
type Type2 = (a: string) => void;
type Type3 = () => void;
const type1Func1: Type1 = ((a: string) => console.log(a)) as Type2; // 타입 통과
const type1Func2: Type1 = (() => {}) as Type3; // 타입 통과
const type2Func1: Type2 = ((a: string, b: number) =>
console.log(a, b)) as Type1; // 타입 에러
const type2Func2: Type2 = (() => {}) as Type3; // 타입 통과
const type3Func1: Type3 = ((a: string, b: number) =>
console.log(a, b)) as Type1; // 타입 에러
const type3Func2: Type3 = ((a: string) => console.log(a)) as Type2; // 타입 에러
함수의 매개변수의 수가 적은 함수가 서브타입이다. 그렇기에 () ⇒ {}
는 모든 함수 타입에 할당이 가능하다.
이변성(bivariance)은 슈퍼타입과 서브타입, 서브타입과 슈퍼타입 서로 할당이 되는 경우를 말한다. 즉, 공변성과 반공변성이 모두 적용이 된다. 이는 매우 유연한 타입 구조이지만 반면, 타입 시스템에 큰 결함을 가져온다.
내가 발견한 타입 시스템 상에서는 통과되었는데도 런타임에서 에러가 발생하는 경우는 이런 TypeScript가 가지고 있는 이변성에서 왔다.
TypeScript의 설계적 결함과 조심해야 할점
TypeScript의 설계적 결함은 유연함을 위해 허용한 이변성에서 오고 이를 제대로 이해하지 못하고 쓴다면 타입 검사를 통과했는데도 런타임 에러를 만날 수 있다. 나는 세 가지의 설계 결함을 발견했다.
첫번째, 함수의 매개변수 타입에서의 이변성이다.
strictFunctionTypes
옵션을 true
로 두면 반공변적으로 동작하기에 그것으로 회피해 볼 수 있으니 자세히는 다루지는 않겠다.
두번째, 메서드의 이변성이다. 함수의 매개변수 타입은 strictFunctionTypes
옵션을 통해서 반공변성을 강제하면서 타입 결함을 회피할 수 있지만 메서드의 이변성과 같은 경우는 옵션을 통해서 이변성을 피할 수가 없다고 한다.
공식 깃헙 문서에서 확인 가능하다. 이변성을 허용하는 근본 이유는 이미 많은 부분이 이변성을 전제로 동작하고 있기 때문이라고 한다. 또, 구조적 타이핑을 사용하기 때문에 명시적인 타이핑과 충돌 문제도 있다고 한다
함수의 예
// strictFunctionTypes 옵션을 켜둔 상태
type SubType = number;
type SuperType = number | string;
interface ObjectType {
func: (param: SuperType) => void;
}
const object2 = {
func: (param: SubType) => {},
};
// 에러가 발생한다.
const object1: ObjectType = object2;
object1의 func
와 object2의 func
의 관계를 보자면 object1의 func의 param
과 object2의 func의 param
관계의 반대가 되기 때문에 object1의 func
는 object2의 func
의 서브타입이다 그렇기에 object2의 func
는 object1의 func
에 할당 할 수 없다

메서드의 예
type SubType = number;
type SuperType = number | string;
interface ObjectType {
method(param: SubType): void;
}
const object2 = {
method(param: SuperType) {},
};
// 에러가 발생하지 않는다
const object1: ObjectType = object2;
type SubType = number;
type SuperType = number | string;
interface ObjectType {
method(param: SuperType): void;
}
const object2 = {
method(param: SubType) {},
};
// 에러가 발생하지 않는다
const object1: ObjectType = object2;
메서드로 구현하게 되면 이변성으로 인해 어떤 방향으로건 할당이 가능하다
type SubType = number;
type SuperType = number | string;
interface ObjectType {
method(param: SuperType): void;
}
const object2 = {
method(param: SubType) {
param.toFixed();
},
};
// 에러가 발생하지 않는다
const object1: ObjectType = object2;
object1.method("으아악"); // 런타임 에러
이런 이변성으로 인해 메서드의 타입 검증이 제대로 이뤄지지 않아 런타임 에러도 만나 볼 수 있다.

param을 숫자로 정의해 놨지만 ObjectType에서는 문자도 받을수 있게 정의 해 놓았기 때문에 타입은 통과 된다. 함수였다면 ObjectType
은 typeof object2
의 서브타입이기 때문에 할당이 되지 않았기에 보지 않아도 될 수 있는 런타임 에러다.
추가로 이런 점을 이용하면 의도적으로 이변성을 만드는 타입도 만들어 내볼 수 있는데 리액트 내부에선 이런 점을 활용하여 이벤트 핸들러를 받고있다.
type EventHandler<E extends SyntheticEvent<any>> = {
bivarianceHack(event: E): void;
}["bivarianceHack"];
메서드로 만든 후에 내보냈기 때문에 EventHandler는 이변성을 가지는 타입으로 재탄생했다.
세번째, 옵셔널로 인한 예외이다. TypeScript가 구조적인 타입검사를 진행하고 또 거기에 옵셔널까지 있기에 생겨버린 예외이다. ( Go나 다른 구조적 타입검사를 하는 언어들을 살펴 보았지만 그 언어들은 옵셔널 프로퍼티를 허용하지 않아서 이런 예외를 만나지 않는 것 같다 )
interface SuperType {
name: string;
}
interface SubType extends SuperType {
age: number;
}
const object2: SubType = {
name: "object1",
age: 2,
};
const object1: SuperType = object2; // 서브타입은 슈퍼타입에 할당 가능하다 통과
interface SuperType {
name: string;
}
interface SubType extends SuperType {
age: number;
}
const object2: SuperType = {
name: "object1",
};
const object1: SubType = object2; // 슈퍼타입은 슈퍼타입에 할당 불가능하다 (타입에러)
간단한 객체 간의 관계를 볼 때 옵셔널이 쓰이지 않는 경우는 기대대로 객체간 서브타입, 슈퍼타입 관계가 확실하기 때문에 안전한 타이핑이 가능하다. 하지만 옵셔널이 쓰이는 경우 일부 상호 할당이 가능하게 되면서 모든게 꼬이게 된다.
interface SuperType {
name: string;
}
interface SubType extends SuperType {
age?: number;
}
const object2: SuperType = {
name: "object1",
};
const object1: SubType = object2; // 위에선 에러가 났지만 옵셔널을 쓰면 통과가 된다
const object3: {
name: string;
age: number;
} = object1; // 타입 에러
보통은 저렇게 할당이 되어도 age는 undefined일 수 있다고 판단이 되기 때문에 falsy가드를 세우게 하고 런타임에서 에러가 안나고 넘어가게 된다. 생산성도 챙기고 어느정도 타입 가드도 강제하기 때문에 평소에 자주 만나는 런타임 에러는 아닐 것 같다.
하지만 위에서 내가 만났던 문제는 타입 자체가 덮어지는 과정에서 타입이 오염되게 되고 타입 검사는 찾아내지 못한 런타임 에러가 발생한다.
나는 타입스크립트의 옵셔널 허용이 위의 예와는 또 다른 이변성을 만들지 않을까 하는 의문을 품었었다. 하지만 아래의 예제는 옵셔널을 사용해도 여전히 공변성을 가지며 타입검사를 해낸다.
type Type1 = {
name: string;
age?: number;
};
type Type2 = {
name: string;
age: number;
};
const object3: Type2 = {
name: "hi",
} as Type1; // 타입 에러
const object4: Type1 = {
name: "123",
age: 123,
} as Type2; // 타입 통과
// Type2는 Type1의 서브타입
옵셔널이 만드는 타입 결함은 이변성으로는 설명되지 않는 또 다른 타입 시스템 결함으로 생각하면 되겠다.
TypeScript를 사용하며 경험했던 결함 문제 돌아보기
const Button = ({ onClickButton }: { onClickButton: () => void }) => {
return <button onClick={onClick}>터져랏</button>;
};
export default function App() {
const clickHandler = (func?: () => void) => {
func?.();
};
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<Button onClickButton={clickHandler} />
</div>
);
}
clickHandler
은 func
를 옵셔널로 받고있고 옵셔널로 받고 있기 때문에 타입 검사에서 예외를 획득 한다
const clickHandler = (func?: () => void) => {
func?.();
};
clickHandler(); // 타입 통과
clickHandler(undefined); // 타입 통과
clickHandler(() => {}); // 타입 통과
clickHandler({ test: "hey" }); // 타입 에러
단독으로 쓰일때는 큰 문제가 없지만 할당을 하게되면 슈퍼타입, 서브타입 관계를 무시하고 할당 하기 때문에 타입이 변질될 우려가 있다.
type StevyEvent = {
type: "click";
};
const clickHandler = (func?: () => void) => {
func?.();
};
const onClickButton: () => void = clickHandler;
// 옵셔널의 효과로 모든 함수의 서브타입인 () => void에 할당
const onClick: (event: StevyEvent) => void = onClickButton;
// onClickButton은 모든 함수의 서브타입인 () => void 이기 때문에
// (event:StevyEvent) => void에 할당 가능
onClick({
type: "click", // 런타임 에러 func is not a function
});
보통 컴포넌트의 Props 타입을 선언할때 전달하는 리액트 이벤트핸들러 함수 타입은 () ⇒ void와 같은 VoidFunction 형태로 많이 정의한다. 알지는 못했지만 VoidFunction은 모든 함수의 서브타입이였기 때문에 할당이 매우 편해서 쓰고 있었던 것 같다. 하지만 리액트의 createReactElement 함수는 항상 ReactEvent를 전달하기 때문에 옵셔널 등과 같이 쓰여 정확한 타입 추적이 어렵게 되버리는 경우 나와 마찬가지로 타입 검사를 피하는 런타임 에러를 만날 수 있다. 필자는 좀더 귀찮더라도 안정적인 타입시스템을 위해 리액트 이벤트 핸들러 타입 함수를 리액트로 부터 import해서 쓰기를 권고한다
import { ReactEventHandler } from "react";
const Button = ({
onClick,
}: {
onClick: ReactEventHandler<HTMLButtonElement>;
}) => {
return <button onClick={onClick}>터져랏</button>;
};
export default function App() {
const onClick = (func?: () => void) => {
func?.();
};
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<Button onClick={onClick} /> // 타입 에러
</div>
);
}
마치며
TypeScript는 강력한 타입 시스템을 제공하지만, 생산성을 위해 다양한 예외를 두고 있어서 완벽한 타입 검증을 하지 않는다. 이변성, 옵셔널 프로퍼티으로 인해 예상치 못한 런타임 에러가 발생할 수 있다. 내가 이 글에서 발견하지 못한 다른 예외들도 있을 것이다. TypeScript를 신뢰하되, 타입 시스템의 한계를 이해하고 조심스럽게 사용해야 한다.