타입스크립트에서의 컴포넌트 추론
나는 꽤 오래 전부터 타입스크립트를 사용해 특정한 컴포넌트만을 걸러낼 수 있을지 궁금했다. 만약 이게 가능하다면 특정한 컴포넌트만을 children으로 받게 만들 수 있어 프로젝트의 전체적인 구조가 단단해질 거라 생각했다. 실제로 특정 컴포넌트를 걸러내기 위해서 나는 타입스크립트를 가지고 아주 다양한 시도를 해보았으며, 이 포스트는 수많은 삽질 속에서 알게 된 내용을 정리한 것이다.
컴포넌트를 이루는 타입들
ReactNode
나는 리액트 컴포넌트의 props로 children을 넘겨야 할 때, 항상 그 타입을 ReactNode로 선언했다. 습관 같은 것이었고 이에 대해 깊게 생각해본 적이 없었다. 결론부터 말하자면 ReactNode는 ReactElement를 포함하는 타입이다. 아래는 리액트에서 선언한 ReactNode 타입의 정의이다. 괴상한 타입 하나를 제외하고 살펴보자면 기본형 타입들과 더불어 ReactPortal, ReactElement, 그리고 Iterable<ReactNode> 타입을 확인할 수 있다.
type ReactNode =
| ReactElement
| string
| number
| Iterable<ReactNode>
| ReactPortal
| boolean
| null
| undefined
| DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES[
keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES
];
Iterable<ReactNode> 타입은 글자 그대로 Iterable한 ReactNode의 덩어리 ─ 배열이라고 생각해도 좋다 ─ 도 ReactNode가 될 수 있다는 의미이다. 아래의 코드를 살펴보면 Switch 컴포넌트 안에 Match 컴포넌트가 여러 개 배치되어있다. 이런 경우 Switch 컴포넌트의 children으로 Iterable한 ReactNode의 덩어리가 전달된다.
<Switch trigger={a} fallback={<>const, let, var를 입력해주세요</>}>
<Match target={"a"}>a</Match>
<Match target={"b"}>b</Match>
</Switch>
const children = (
<Match target={"a"}>a</Match>
<Match target={"b"}>b</Match>
)
그런데 생각해보면 조금 이상하다. JSX 식에는 부모 요소가 하나만 있어야 하는데, children을 살펴보면 부모 요소가 여럿이다. 이는 런타임 때 실제로 넘어가는 값이 어떻게 다른지를 확인해보면 된다. 만약 부모 요소가 하나라면 런타임 때 children은 object이지만, 부모 요소가 여럿일 경우에는 object의 array로 표현되기 때문이다.
이를 통해 우리는 children으로 넘겨주는 값이 Iterable<ReactNode>일 때, 부모 요소가 여럿이 된다는 사실을 알 수 있다. JSX 식에는 부모 요소가 하나만 있어야 하기 때문에 나는 평소 children을 프래그먼트로 감싸서 사용해왔다. 하지만 실제로는 프래그먼트 없이도 전혀 문제 없이 동작하는 것을 확인할 수 있다.
// 기존 방식
export default function Test({children}: {children: ReactNode}) {
return <>{children}</>
}
// 테스트해본 방식
export default function Test({children}: {children: ReactNode}) {
return children
}
조금 햇갈리기는 하지만 A는 안 되고 B가 되는 걸 보면, children이 여러 컴포넌트로 이루어진 경우 실제로는 이터러블한 무언가로 전달된다는 사실을 알 수가 있다. 이것이 바로 Iterable<ReactNode> 타입이 가진 의미이다.
export const A = () => {
return (
<Match target={"a"}>a</Match>
<Match target={"b"}>b</Match>
)
}
export const B = () => {
return [
<Match target={"a"}>a</Match>,
<Match target={"b"}>b</Match>
]
}
따라서 children을 무조건 이터러블한 값으로 제한하면 이터레이터 프로토콜을 사용해 자식으로 받은 컴포넌트를 하나씩 검증하거나, find 메소드와 같이 특정 조건을 만족하는 컴포넌트 만을 리턴하게 만들 수도 있다.
export const Switch = ({ children, trigger, fallback = "" }: {children: Iterable<ReactElement<{id: string}, "div">>, trigger: Key, fallback?: ReactNode}) => {
const iter = children[Symbol.iterator]()
while (true) {
const {value, done} = iter.next()
if (done) break;
if (value.props.id === trigger) return value
}
return <>{fallback}</>
}
ReactElement
ReactNode에서 살펴보았던 것처럼, ReactElement는 ReactNode를 이루는 여러 타입 중 하나이다. 그렇다면 ReactElement는 무엇인가. 바로 React 컴포넌트의 반환하는 특정한 구조를 가진 객체를 표현하는 타입이다. 따라서 당연하게도 ReactNode와 달리 문자열이나 Iterable<ReactNode> 타입을 리턴하는 경우 타입 에러가 발생하게 된다.
// ReactNode를 반환하는 함수
function renderNode(): ReactNode {
return (
<div>
<h1>Hello, World!</h1>
<p>This is a paragraph.</p>
</div>
);
}
function renderNode(): ReactNode {
return "Hello, World!";
}
// ReactElement를 반환하는 함수
function renderElement(): ReactElement {
return (
<div>
<h1>Hello, World!</h1>
<p>This is a paragraph.</p>
</div>
);
}
function renderElement(): ReactElement {
return "Hello, World!";
} // 타입 에러!!
위의 경우에서 발생하는 에러 내용을 살펴보면 "'string' 형식은 'ReactElement<any, string | JSXElementConstructor<any>>' 형식에 할당할 수 없습니다."라고 나온다. 이는 ReactElement 타입의 정의를 살펴보면서 이야기해볼 수 있겠다. ReactElement는 두 개의 제네릭을 받을 수 있는데, P는 해당 ReactElement가 어떤 props로 이루어지는지, T는 해당 ReactElement를 생성한 게 무엇인지를 나타낸다.
제네릭 타입 T를 살펴보면 JSXElementConstructor라는 타입이 존재하는데, 간단히 설명하자면 이 타입은 함수형 컴포넌트로부터 생성되었는지, 클래스형 컴포넌트로부터 생성되었는지를 나타낸다. 만약 둘 다 아닐 경우라면 해당 ReactElement를 생성한 것은 string으로서 이는 div나 span 등의 기본 컴포넌트를 의미한다.
interface ReactElement<
P = any,
T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>,
> {
type: T;
props: P;
key: string | null;
}
JSX.Element
JSX.Element 타입은 ReactElement 타입을 extends 했을 뿐인 간단한 타입이다. 심지어 두 개의 제네릭 타입을 any로 extends하고 있는 만큼 가장 넓은 범위의 ReactElement 타입과 동일하다고 볼 수 있다.
interface Element extends React.ReactElement<any, any> {}
그리고 타입스크립트는 어떤 타입을 추론하는 데 있어서 가능한 넓은 타입으로 추론하게 된다. const a = "123"일 때 a의 타입이 string으로 추론되는 것도 그러한 까닭이다. 따라서 타입 어노테이션을 달거나 타입 단언을 하지 않는 이상 리액트 컴포넌트는 언제나 JSX.Element로 추론된다.
문제는 리액트 컴포넌트가 언제나 JSX.Element(= ReactElement<any, any>)로 추론되기 때문에, 타입 어노테이션을 잘못 달아준다고 해도 타입스크립트는 이를 알아채지 못한다.
// a는 JSX.Element로 추론됨
const a = <div id="a">airplane</div>
// 당연하게도 여기서는 타입 에러는 나지 않음
const a: ReactElement<{id: string}, "div"> = <div id="a">airplane</div>
// 문제는 여기서도 타입 에러가 안 남!
const a: ReactElement<{id: string}, "span"> = <div id="a">airplane</div>
// 이는 JSX.Element와 ReactElement의 관계가 아래와 같기 때문이다
// const a: any = "123"
// const b: number = a
타입스크립트로 컴포넌트를 유추할 수 없는 이유
자 이제 처음으로 돌아와보자. 나는 Switch 컴포넌트를 만들 때 타입스크립트를 사용해서 id props를 가지고 있는 div 태그만을 children으로 받고자 했다. 내 시도는 성공했을까?
export const Switch = ({ children, trigger, fallback = "" }: {children: Iterable<ReactElement<{id: string}, "div">>, trigger: Key, fallback?: ReactNode}) => {
const iter = children[Symbol.iterator]()
while (true) {
const {value, done} = iter.next()
if (done) break;
if (value.props.id === trigger) return value
}
return <>{fallback}</>
}
당연히 실패했다. 이는 가능한 타입을 좁히기 위해 내가 제네릭 타입에 아무리 용을 써봤자 ReactElement<any, any>는 항상 타입 체크를 통과해 버리기 때문이다. 타입스크립트의 any가 갖는 고질적인 문제점이다. 따라서 현재의 리액트에서는 1) 모든 컴포넌트가 JSX.Element로 추론되며, 2) 무슨 방법을 쓰던 JSX.Element로 추론되는 것을 막을 방법이 없기 때문에 타입스크립트를 사용해서 특정 컴포넌트를 유추하고 이를 제한할 수가 없는 것이다.
물론 타입스크립트가 아니라 자바스크립트를 사용하면 런타임에 컴포넌트를 특정할 수는 있다. 앞서 언급했던 바와 같이 ReactElement는 React 컴포넌트의 반환하는 특정한 구조를 가진 객체이고, 이 객체 안에는 해당 객체를 생성한 컴포넌트를 특정할 수 있는 사실상의 유일한 수단인 type이라는 프로퍼티가 존재한다.
이를 활용하면 아래와 같이 value.type이 특정 컴포넌트인지를 확인하고, 그렇지 않을 경우 에러를 던지는 식으로 반드시 지정한 컴포넌트만을 사용하게 강제할 수 있다. 하지만 런타임까지 안 가려고 타입스크립트를 쓰는 마당에 이런 방식이 옳은지는 좀 더 고민해봐야할 것 같다. 리액트가 컴포넌트의 타입을 JSX.Element 대신 좀 더 세밀한 타입으로 추론하면 모든 문제가 해결될텐데….
export const Switch = ({ children, trigger, fallback = "" }: {children: Iterable<ReactElement>, trigger: Key, fallback?: ReactNode}) => {
const iter = children[Symbol.iterator]()
while (true) {
const {value, done} = iter.next()
if (done) break;
if (value.type !== Match) throw new Error("Match 컴포넌트만 사용할 수 있습니다.");
if (value.props.id === trigger) return value
}
return <>{fallback}</>
}
블로그의 정보
Ayden's journal
Beard Weard Ayden