리액트 컴포넌트를 타입스크립트 제네릭 함수처럼 쓰기

원글: 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}를 인라인 타입으로 작성했지만, BaseEntityBaseNode 타입을 선언하고 그 타입을 사용해도 된다.

{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>가 옵셔널이고 사용자가 아무것도 선택하지 않을 수 있다면, 우리는 컴포넌트가 onSelectnull을 보낼 수 있도록 해야 할 것이다. 그러므로 타입스크립트에게 "requiredtrueonSelect에 전달되는 값의 타입이 optionsid 타입과 같고, requiredtrue가 아니면 그 타입이 null일 수 있다."라고 알려야 한다. 아래에 대략적인 구현 예제가 있다. requiredfalse여서 아무것도 선택하지 않을 때를 위해 <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를 반환하지만, requiredtrue가 아닐 때는 null을 반환할 수도 있다. 3번째 사용되는 부분은 우리의 제네릭 순항이 끝났다는 것을 보여준다. 장난스러운 물 언덕을 돌다가 어쩔 수 없이 북태평양을 표류하는 봉변을 당하는 것처럼 말이다.

선택적 제네릭: 까다로운 부분들

제네릭 리액트 컴포넌트를 사용할 때 겪을 어려운 점 두 가지가 있다. 첫 번째는 제네릭 변수 타입의 quirk이고, 다른 하나는 타입스크립트가 가끔 어떤 복잡한 조건을 인식하지 못할 때 발생한다.

먼저... 제네릭을 사용하지 않는 함수 내부이다. 타입스크립트는 전달된 값을 축소하는데 능숙하다.

const makeBigger = (stringOrNumber: string | number) => {
  if (typeof stringOrNumber === 'string') {
    return stringOrNumber.toUpperCase();
  }
  return stringOrNumber * 4;
};

타입스크립트가 추론의 힘을 통해 stringOrNumbernumber 타입임을 알기 때문에 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> 컴포넌트로 돌아가 보자. 타입스크립트는 requiredfalsy일 때 <option> DOM 엘리먼트를 렌더링 하는 tsx와, 그 <option> 엘리먼트의 value 애트리뷰트가 <select> 엘리먼트의 onSelect 이벤트에 전달된다는 것을 실제로 이해하지 못한다.

게다가, 타입스크립트는 props.requiredtrue가 아닐 때 props.onSelectnull을 전달한다는 사실도 알지 못한다. 그리고 이런 내용을 유추할 수 없기 때문에, 타입스크립트에게 컴포넌트 로직이 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>idname을 넘기는 대신에 기저에 깔린 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 접근법

마지막으로...

일반적인 리액트 컴포넌트에서 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 없이 그냥 정의한 것을 보자. 이렇게 작성한 이유는 타입 별명 중, 특히 확장되는 타입 변수에 실제로 의존하지 않기 때문이다. 하지만 그 타입을 내보내고 있고, 그 타입이 정확해야 하므로 약간 반복적으로 작성했다. 그리고 문자열과 불리언을 확장해서 정의했다.


자, 이제 모두 끝났다. 이 글을 읽어줘서 고맙고, 여러분은 정말 멋지다. 여기 안경을 쓴 강아지 사진이 있다.

강아지가 안경을 쓸 때 팔을 귀밑에 놓는다는 걸 알고 있는가? 나도 몰랐다!

Photo by Cookie the Pom on Unsplash

Unsplash 에 있는 Cookie the Pom 의 사진.

더 많은 내용은 plainenglish.io 에 있다.