React 제네릭 컴포넌트 패턴 - <T extends Record<string, any>>로 row/item 타입 추론

import * as React from 'react'

type Props<TRow> = {
  rows: TRow[]
  renderRow: (row: TRow, index: number) => React.ReactNode
}

const Table = <TRow extends Record<string, any>>({
  rows,
  renderRow,
}: Props<TRow>) => {
  return (
    <table>
      <tbody>{rows.map((row, index) => renderRow(row, index))}</tbody>
    </table>
  )
}

function App() {
  return (
    <Table
      rows={[{ name: 'lee' }]}
      renderRow={(row, index) => (
        <tr key={index}>
          <td>{row.name}</td>
        </tr>
      )}
    />
  )
}

row.name 이 되는 이유 — TS 는 Props<TRow> 와 넘긴 값을 나란히 놓고 TRow 를 줍는다.1

Props<TRow> 와 넘긴 props 를 칸마다 나란히 놓는다. rows: TRow[] 자리에는 { name: string }[] 이 와서 배열 안의 맨몸 TRow 자리에서 { name: string } 을 후보로 줍는다. renderRow 의 row 는 타입을 안 적었으니 주울 게 없다. extends Record<string, any> 는 넘긴 값이 아니라 후보가 아니다. 후보에서 고른 TRow 를 제약에 맞춰 보고, 안 맞으면 TRow 를 제약으로 바꿔 끼운 채 검사해 에러를 낸다. 후보로 TRow = { name: string } 이 정해지면 그 타입이 거꾸로 renderRow 의 row 로 흘러가서 row.name 을 쓸 수 있다.

후보가 여럿이면 자리에 따라 고른다 — 화살표 함수를 직접 쓰면 매개변수를 다 적었는지에 따라 자리가 바뀐다.2

rows={dogs} 는 두 경우 모두 내주는 쪽(공변) 후보 Dog 를 낸다. renderRow 의 매개변수를 다 적으면(row: Animal, index: number) 함수 타입끼리 비교해 Animal 이 받는 쪽(반변) 후보가 되고, Dog 가 Animal 에 들어가니 내주는 쪽이 이겨 TRow = Dog. 하나라도 덜 적으면((row: Animal) 또는 index 에 타입 없음) 적힌 타입만 읽어 Animal 을 내주는 쪽 후보로 넣고, Dog 와 Animal 의 공통 상위 타입을 골라 TRow = Animal. 화살표 함수 리터럴일 때 얘기다 — 이미 만든 함수를 넘기면 덜 적어도 Dog, function 표현식은 다 적어도 Animal.

TRow 를 두 번째로 쓰는 prop 은 NoInfer 로 줍기에서 뺀다 (TS 5.4+).

type Props<TRow> = {
  rows: TRow[]
  renderRow: (row: TRow, index: number) => React.ReactNode
  selectedRow?: NoInfer<TRow>
}

rows={[{ name: ‘lee’ }]} selectedRow={{ name: ‘kim’, age: 1 }} 를 넘긴다. NoInfer 가 없으면 두 자리 모두에서 후보를 줍고, 둘 다 객체 리터럴이라 union 으로 합쳐져 TRow 가 넓어지고 에러가 나지 않는다. NoInfer 가 있으면 selectedRow 는 후보로 줍지 않아 TRow = { name: string } 이 되고, selectedRow 를 그 타입으로 검사하다 age 에서 에러가 난다.

extends Record<string, any> 는 원시값만 막는다.

Record<string, any> 는 { [key: string]: any } 로 펼쳐진다. 값이 any 인 인덱스 시그니처는 원시값만 빼고 다 받아서 type 별칭 객체·interface·배열·함수가 통과하고 원시값만 막힌다. Record<string, unknown> 은 암묵적 인덱스 시그니처가 있는 type 별칭 객체만 통과한다 — interface 는 선언 병합으로 속성이 늘 수 있어 암묵적 인덱스 시그니처를 받지 못하고, 배열·함수도 막힌다. object 는 원시값만 막는다.

import * as React from 'react'
import { UseComboboxProps, useCombobox } from 'downshift'

function Combobox<T extends Record<string, any>>(
  props: UseComboboxProps<T> & {
    renderItem: (item: T) => React.ReactNode
  }
) {
  const combobox = useCombobox(props)

  return (
    <div>
      <input
        placeholder="구성원을 검색해주세요."
        {...combobox.getInputProps()}
      />
      <div {...combobox.getMenuProps()}>
        {combobox.isOpen &&
          props.items.map((item, index) => (
            <div key={item.id} {...combobox.getItemProps({ item, index })}>
              {props.renderItem(item)}
            </div>
          ))}
      </div>
    </div>
  )
}

function App() {
  return (
    <Combobox
      items={[{ id: 1, name: 'eunsoo' }]}
      itemToString={(item) => `${item?.id}`}
      renderItem={(item) => {
        return <div>{item.name}</div>
      }}
      onInputValueChange={({ inputValue }) => {
        console.log(inputValue)
      }}
    />
  )
}

Footnotes

  1. norswap — How TypeScript infers type variables: “Every time the walk reaches a bare type parameter on the target side, record the corresponding source type as a candidate.” ↩

  2. TypeScript v5.9.3 checker.ts — 매개변수를 덜 적거나(L39568) 타입을 안 적은 게 있는(L39548) 함수 리터럴은 적힌 타입만 그대로 후보로 넣고, 같은 줄 후보끼리는 “infer a common supertype”. 두 줄이 다 있으면 “we prefer the co-variant inference if … it is assignable to some contra-variant inference”. ↩

#480