원글: https://javascript.plainenglish.io/react-components-as-typescript-generic-functions-8aa83afff597 - David Gilbertson
컴포넌트를 리스트 형태로 렌더링한다고 가정해보자. 유동적으로 렌더링 되도록 한다면 다음과 같을 것이다.
<List
items={products}
renderItem={product => (
<p>
{product.name}: ${product.proice}
</p>
)}
/>
혹은 아래와 같을수도 있다.
<List
items={notProducts}
renderItem={notAProduct => (
<p>
{notAProduct.name}: {notAProduct.code}
</p>
)}
/>
내부 구현은 아마 다음과 같을 것이다.
const List = (props: {
items: any[];
renderItem: (item: any) => React.ReactNode;
}) => (
<>{props.items.map(props.renderItem)}</>
);
물론, 문제는 renderItem
콜백의 변수 타입이 any
라는 것이다.
그래서 부지런한 코드 리더인 여러분이 발견한 proice
오타를 감지하지 못한다. 적절한 타입을 지정하기 위해 renderItem
에 전달하는 객체가 product
라고 수동으로 알릴 수 있다.
하지만, 가여운 <List>
사용자가 이미 알고있는 것 들을 다시 정의해야 한다는 걸 의미한다. 그리고 만약 sortItem
이나 filterItem
, validateItem
혹은 onItemClick
prop을 써야 할 때, 별도의 타입을 지정하기 위해 우리의 키보드는 닳아 없어질 것이다. 아마 더 좋은 방법이 있을 것이다.
const List = <ItemType extends any>(props: {
items: ItemType[];
renderItem: (item: ItemType) => React.ReactNode;
}) => (
<>{props.items.map(props.renderItem)}</>
);
제네릭 사용이 처음이라면, 이 글은 제네릭을 시작하기엔 좋지 않을 것이다(대신, 이 문서를 보면된다)
. 하지만 여전히 읽고 있다면, 읽지 말라고 말하는 건 조금 무례한 것 같으니 위 코드의 의미를 간단히 설명하겠다.
"타입스크립트야, 이 컴포넌트의 사용자는 items
prop의 배열을 넘길 예정이야. 어떤 타입의 배열일지는 모르겠지만, 그중 한 가지 타입을 renderItem
함수로 다시 넘겨야 해."
이제 우리가 넘긴 타입의 콜백에서 추론되어 결과적으로 적절한 타입을 얻을 수 있다.
심지어 다음과 같은 것도 가능하다.
짜잔~(역: Magical.)
출발이 좋다고 할 수 있지만, <List>
내부에서는 어떤 item
이 주어지는지 알 수 없다. 이 컴포넌트의 소비자에게는 아름답고 유연하지만, 실제 리스트를 렌더링 하기 시작하면 문제점들이 하나씩 보이기
시작한다. 일례로 각 리스트 아이템은 key
를 필요로 한다.
const List = <ItemType extends any>(props: {
items: ItemType[];
renderItem: (item: ItemType) => React.ReactNode;
}) => (
<ul>
{props.items.map(item => (
<li key={item.id}>{props.renderItem(item)}</li>
))}
</ul>
);
모두 알다시피 any
타입은 id
라는 속성을 가지고 있지 않으며, ItemType
의 타입이 any
이므로 item.id
부분에서 타입 에러가 발생한다.
extends
우측에 정의한 것처럼, 컴포넌트에 전달되는 item
의 필수 속성을 지정해서 문제를 수정할 수 있다.
const List = <ItemType extends {id: string}>(props: {
items: ItemType[];
renderItem: (item: ItemType) => React.ReactNode;
}) => (
<ul>
{props.items.map(item => (
<li key={item.id}>{props.renderItem(item)}</li>
))}
</ul>
);
이제 "어떤 item
이 전달될지 모르지만, id
속성은 반드시 존재한다는 건 안다."라고 말할 수 있다.
필자는 간결함을 위해 {id: string}
를 인라인 타입으로 작성했지만, BaseEntity
나 BaseNode
타입을 선언하고 그 타입을 사용해도 된다.
{id: string}
는 id
속성만 가진 일반 객체라는 걸 의미하진 않는다. id
속성을 가진 함수나 배열일 수도 있다. 마찬가지로, 우리 생각과는 다르게 {}
는 객체를 의미하지
않는다. 123 extends {}
는 참이다. 그리고 123 extends Object
도 참이지만, 123 extends object
는 거짓이다.
메모: 다시 말해, 자바스크립트는 중괄호 한 쌍으로 만들 수 있는 것에 대한 설명이 부족하며, 키와 값이 있고, 배열이나 함수가 아닌, 거대한 혼돈의 원천이다. 좋은 소식 한 가지는 제네릭 함수에서는 신경을 쓸
필요가 없으며, 그저 컴포넌트가 반드시 알아야 할 것만 정의하면 된다는 것이다. 위에 작성한 <List>
의 소비자는 id 속성을 가진 함수 배열을 넘겨받길 원하고 있다. 이건 이상하지만 괜찮다. 왜냐면,
타입스크립트는 {id: string}
이 함수일 수 있다고 이해하기 때문이다.
여기까지는 간단했다. 하지만 이렇게 쉽게 타입을 추론할 수 없다면 어떨까? 아래의 <Select>
를 보자. 똑똑한 인간인 우리는 보자마자 선택된 id가 Size
enum 중 하나라고 이해하겠지만,
타입스크립트는 이해하지 못한다.
<Select
onSelect={id => {
// id로 무언가 하기
}}
>
<option value={Size.Small}>Small</option>
<option value={Size.Medium}>Medium</option>
<option value={Size.Large}>Large</option>
</Select>
뭔가 이상함을 느꼈다면, 아래 문법을 사용해서 제네릭 변수 타입을 사용해서 명시적으로 전달할 수 있다.
<Select<Size>
onSelect={id => {
// id로 무언가 하기
}}
>
<option value={Size.Small}>Small</option>
<option value={Size.Medium}>Medium</option>
<option value={Size.Large}>Large</option>
</Select>
하지만 리액트 컴포넌트와 타입스크립트 제네릭을 사용할 때, DOM을 전달하는 대신에 객체 배열을 넘겨준다면 일이 더 쉽게 끝날 것이다.
<Select
onSelect={id => {
// id로 무언가 하기
}}
options={[
{id: Size.Small, name: 'Small'},
{id: Size.Medium, name: 'Medium'},
{id: Size.Large, name: 'Large'},
]}
/>
이를 통해 전달된 데이터에 들어있는 것과, onSelect
이 호출될 때 어떤 타입이 넘어오는지 알 수 있다.
<Select>
내부는 아마도 아래와 같을 것이다.
const Select = <IdType extends string>(props: {
options: Array<{
id: IdType;
name: string;
}>;
selectedItemId?: IdType;
onSelect: (id: IdType) => void;
}) => (
<select
value={props.selectedItemId ?? ''}
onChange={e => props.onSelect(e.target.value as IdType)}
>
{props.options.map(option => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
);
<List>
예제를 통해 타입스크립트가 전달된 객체의 타입에서 ItemType
을 사용하는 것을 확인했고, <Select>
를 통해 우리는 한 단계 더 깊게 들어가서 전달된 배열의 객체의 속성 타입을 추론해
보았다.
(이 <Select>
예제를 다른 부분에서도 사용하겠지만, 간단하게 설명하기 위해 <optgroup>
엘리먼트는 사용하지 않으려 한다. 여러분이 이 부분을 이해해 주길 바란다.)
제네릭 타입 변수
(위 스니펫에서 봤던 IdType
같은)는 이상하다. 자바스크립트 세계에는 이와 동일한 개념이 존재하지 않는다. 필자는 테스트 프레임워크의 spy
와 약간 닮았다고 말하고 싶다. 특정 시점의
상태를 저장하기 위해 사용하기 때문이다. 타입 변수는 "이 메서드가 어떤 것과 함께 호출되는지 보자"라는게 아니라, "이 슬롯(slot)에 어떤 종류의 데이터가 들어오는지 보자"라고 할 수 있다.
함수의 인수와는 개념적으로 큰 차이가 있지만, 사용하는 모양은 비슷하다.
type BaseEntityWithFancyId<IdType> = {
id: IdType;
name: string;
};
const Select = <IdType extends string>(props: {
options: Array<BaseEntityWithFancyId<IdType>>;
selectedItemId?: IdType;
onSelect: (id: IdType) => void;
}) => ({
// ...나머지 부분들
})
props.onSelect
에서 반환되는 값의 타입이 모두 ID
로 전달되는 타입과 같으면 아주 좋을 것이다. 하지만 그렇지 않을 수도 있다.
예를 들어, <Select>
가 옵셔널이고 사용자가 아무것도 선택하지 않을 수 있다면, 우리는 컴포넌트가 onSelect
로 null
을 보낼 수 있도록 해야 할 것이다. 그러므로
타입스크립트에게 "required
가 true
면 onSelect
에 전달되는 값의 타입이 options
의 id
타입과 같고, required
가 true
가 아니면 그 타입이 null
일 수
있다."라고 알려야 한다. 아래에 대략적인 구현 예제가 있다. required
가 false
여서 아무것도 선택하지 않을 때를 위해 <option>
을 추가했다. 그리고 비어있는 option
이 선택되었을
때 onSelect
함수에 null
을 넘겨주는 onChange
함수도 있다.
type Nullable<IdType, RequiredType> = RequiredType extends true
? IdType
: IdType | null;
const Select = <IdType extends string, RequiredType extends boolean>(props: {
options: Array<{id: IdType; name: string}>;
selectedItemId: Nullable<IdType, RequiredType>;
onSelect: (id: Nullable<IdType, RequiredType>) => void;
required?: RequiredType;
}) => (
<select
value={props.selectedItemId ?? 'NULL_SELECTION'}
required={props.required}
onChange={e => {
const selectedId = e.target.value === 'NULL_SELECTION' ? null : e.target.value;
props.onSelect(selectedId as Nullable<IdType, RequiredType>);
}}
>
{!props.required && <option value="NULL_SELECTION">None selected</option>}
{props.options.map(option => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
);
필자는 Nullable
을 타입 별명으로 만들었다. 이 타입은 ID
가 전달되면 ID
를 반환하지만, required
가 true
가 아닐 때는 null
을 반환할 수도 있다. 3번째 사용되는 부분은
우리의 제네릭 순항이 끝났다는 것을 보여준다. 장난스러운 물 언덕을 돌다가 어쩔 수 없이 북태평양을 표류하는 봉변을 당하는 것처럼 말이다.
제네릭 리액트 컴포넌트를 사용할 때 겪을 어려운 점 두 가지가 있다. 첫 번째는 제네릭 변수 타입의 quirk이고, 다른 하나는 타입스크립트가 가끔 어떤 복잡한 조건을 인식하지 못할 때 발생한다.
먼저... 제네릭을 사용하지 않는 함수 내부이다. 타입스크립트는 전달된 값을 축소
하는데 능숙하다.
const makeBigger = (stringOrNumber: string | number) => {
if (typeof stringOrNumber === 'string') {
return stringOrNumber.toUpperCase();
}
return stringOrNumber * 4;
};
타입스크립트가 추론의 힘을 통해 stringOrNumber
가 number
타입임을 알기 때문에 stringOrNumber * 4
같은 명령은 안전하게 내릴 수 있다.
반면에, 기능적으로 동일한 아래 코드 조각은 제대로 동작하지 않는다.
const makeBigger = <T extends string | number>(stringOrNumber: T) => {
if (typeof stringOrNumber === 'string') {
return stringOrNumber.toUpperCase();
}
return (stringOrNumber * 4);
};
알다시피, 타입스크립트는 제네릭 변수의 타입을 축소
하지 못한다. 필자가 이해한 바로는, 첫 번째 예제에서 문자열이나 숫자 중 하나여야 하는 값의 타입을 다루므로, 한 가지가 아니라면 틀림없이 나머지 타입일
것이다. 두 번째 예제에서는 타입 변수의 타입을 다루고, 이는 string
이나 number
혹은 string | number
일 수 있다.
필자가 내부 설계를 확실히 이해하지는 못하지만, 언젠가는 고쳐질 것이다. 하지만 여기서 중요한 점은 제네릭 변수는 축소하지 못한다는 점이다. 이게 첫 번째 어려운 점이다.
어려운 점 두 번째. <Select>
컴포넌트로 돌아가 보자. 타입스크립트는 required
가 falsy
일 때 <option>
DOM 엘리먼트를 렌더링 하는 tsx와, 그 <option>
엘리먼트의 value
애트리뷰트가 <select>
엘리먼트의 onSelect
이벤트에 전달된다는 것을 실제로 이해하지 못한다.
게다가, 타입스크립트는 props.required
가 true
가 아닐 때 props.onSelect
에 null
을 전달한다는 사실도 알지 못한다. 그리고 이런 내용을 유추할 수 없기 때문에,
타입스크립트에게 컴포넌트 로직이 prop 타입과 일치한다는 것을 보장해 주어야 한다. 이를 위해서 아래처럼 작성해야 한다.
props.onSelect(selectedId as Nullable<IdType, RequiredType>);
여러분의 컴포넌트가 위의 컴포넌트보다 더 간단하고 두 가지 시나리오(필요하거나 필요하지 않거나)만 존재한다면, 두 개의 prop 세트를 정의할 수도 있다. 다른 말로, 타입스크립트에게 "이 컴포넌트는 두 개의 모드 중 하나로 동작한다."라고 알려주는 것이다.
type BaseProps<IdType> = {
options: Array<{id: IdType; name: string}>;
};
type PropsWhenOptional<IdType> = BaseProps<IdType> & {
required?: false;
selectedItemId?: IdType | null;
onSelect: (id: IdType | null) => void;
};
type PropsWhenRequired<IdType> = BaseProps<IdType> & {
required: true;
selectedItemId?: IdType;
onSelect: (id: IdType) => void;
};
const Select = <IdType extends string>(
props: PropsWhenOptional<IdType> | PropsWhenRequired<IdType>,
) => (
<select
value={props.selectedItemId ?? 'NULL_SELECTION'}
required={props.required}
onChange={e => {
const value = e.target.value as IdType;
if (!props.required) {
const selectedId = !props.required && e.target.value === 'NULL_SELECTION' ? null : value;
props.onSelect(selectedId);
} else {
props.onSelect(value);
}
}}
>
{!props.required && <option value="NULL_SELECTION">None selected</option>}
{props.options.map(option => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
);
이렇게 하면 Nullable
타입은 사라지지만, 타입스크립트가 올바른 타입을 props.onSelect
에 전달한다는 걸 보장하려면 타입 축소를 일으켜야 한다. 아니면 @ts-ignore
를 사용해도 된다.
한편, 이 방법은 복잡도를 증가시키므로, 실제로 여러분이 원하는 방향의 수정이 아닐 수 있다. 하지만, 조금 다르게 생각해 보면 타입스크립트는 프로덕션 빌드에서는 사라진다. 이런 차별화된 통합 접근을 통해서 여러분은 실제로 프로덕션에 복잡성만 추가하는 것이다. 하지만 이런 복잡함은 타입스크립트의 장점을 위해 존재한다. 필자는 이런 방식의 개발은 하면 안 된다고 생각한다. 그리고 여러분도 그렇게 생각하길 바란다.
(함수 오버 로딩도 사용할 수 있겠지만, prop 통합의 차별화와 동일한 문제에 직면할 것이다. 그리고 문법도 지저분해진다.)
이제 거의 끝나가는 것일까? 이제 마지막 단계에 왔다.
잠깐. 필자의 머릿속에 여러분이 몰라도 될 한 가지 복잡한 내용이 떠올랐다.
만약, 여러분의 <Select>
에 id
와 name
을 넘기는 대신에 기저에 깔린 HTML을 미러링 해서 소비자에게 <option>
애트리뷰트를 그들만의 두근대는 가슴으로 전달할 수 있도록 해준다면
어떨까?
이를 위해서, <option>
컴포넌트/엘리먼트의 props/attributes를 확장해야 한다. 그리고 각 value
속성으로 onSelect
로 어떤 타입을 전달할지
파악한다. React.ComponentProps<'option'>
를 확장한 제네릭 타입 별칭을 사용해보자.
type Nullable<IdType, RequiredType> = RequiredType extends true ? IdType : IdType | null;
type OptionProps<IdType> = React.ComponentProps<'option'> & {
value: IdType;
};
const Select = <IdType extends string, RequiredType extends boolean>(props: {
options: Array<OptionProps<IdType>>;
selectedItemId: Nullable<IdType, RequiredType>;
onSelect: (id: Nullable<IdType, RequiredType>) => void;
required?: RequiredType;
}) => (
<select
value={props.selectedItemId ?? 'NULL_SELECTION'}
required={props.required}
onChange={e => {
const selectedId = e.target.value === 'NULL_SELECTION' ? null : e.target.value;
props.onSelect(selectedId as Nullable<IdType, RequiredType>);
}}
>
{!props.required && <option value="NULL_SELECTION">None selected</option>}
{props.options.map(option => (
<option key={option.value} {...option} />
))}
</select>
);
여러 우여곡절이 많았지만, 전달된 옵션 객체는 <option>
엘리먼트 자리에 spread 될 수 있고 이 타입은 모두 잘 동작할 것이다. 이제 위 컴포넌트를 사용할 때, disabled
같은 <option>
의 모든 유효한 애트리뷰트를 넘길 수 있게 되었다.
<Select
selectedItemId={selectedItemId}
onSelect={setSelectedItemId}
options={[
{value: Size.Small, children: 'Small'},
{value: Size.Medium, children: 'Medium'},
{value: Size.Large, children: 'Large', disabled: true},
]}
required
/>
메모: 만약 모든 애트리뷰트를 드러내는 대신에 Omit<>
이나 Pick<>
을 사용한다면, 컴포넌트에 특정 prop은 넘기지 못하도록 막을 수도 있다.
마지막으로...
일반적인 리액트 컴포넌트에서 prop의 타입을 얻고 싶다면, React.ComponentProps(typeof Select)
와 같이 작성할 것이다. 하지만 이 방법은 제네릭 컴포넌트에서는 잘 동작하지 않는다.
왜냐면 여러분은 호출한 위치에 따라서 다양한 타입을 얻게 될 것이기 때문이다. 제네릭
컴포넌트는 어떤 종류의 데이터를 전달할지 모르는 상태에서 유용한 타입을 제공하지 않는다.
그러므로 아래 예제처럼 명시적으로 prop을 export
하고 제네릭 변수로 전달해야 한다.
type Nullable<IdType, RequiredType> = RequiredType extends true ? IdType : IdType | null;
export type SelectProps<IdType extends string, RequiredType extends boolean> = {
options: Array<{id: IdType; name: string}>;
selectedItemId: Nullable<IdType, RequiredType>;
onSelect: (id: Nullable<IdType, RequiredType>) => void;
required?: RequiredType;
};
const Select = <IdType extends string, RequiredType extends boolean>(
props: SelectProps<IdType, RequiredType>,
) => (
<select
value={props.selectedItemId ?? 'NULL_SELECTION'}
required={props.required}
onChange={e => {
const selectedId = e.target.value === 'NULL_SELECTION' ? null : e.target.value;
props.onSelect(selectedId as Nullable<IdType, RequiredType>);
}}
>
{!props.required && <option value="NULL_SELECTION">None selected</option>}
{props.options.map(option => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
);
props
의 타입을 type SelectProps<IdType, RequiredType> = …
처럼 extends
없이 그냥 정의한 것을 보자. 이렇게 작성한 이유는 타입 별명 중, 특히 확장되는 타입
변수에 실제로 의존하지 않기 때문이다. 하지만 그 타입을 내보내고 있고, 그 타입이 정확해야 하므로 약간 반복적으로 작성했다. 그리고 문자열과 불리언을 확장해서 정의했다.
자, 이제 모두 끝났다. 이 글을 읽어줘서 고맙고, 여러분은 정말 멋지다. 여기 안경을 쓴 강아지 사진이 있다.
강아지가 안경을 쓸 때 팔을 귀밑에 놓는다는 걸 알고 있는가? 나도 몰랐다!
Unsplash 에 있는 Cookie the Pom 의 사진.
더 많은 내용은 plainenglish.io 에 있다.