[TS] HTMLElement의 type 상속받기

36050 단어 typescripttypescript
// App.tsx
import { CustomButton } from './components/html/CustomButton'

function App() {
  return (
  	<div>
    	<CustomButton variant='primary' onClick={() => console.log('Clicked')}>
      	Primary Button
      </CustomButton>
    </div>
  )
}

// CustomButton.tsx
import React from 'react'

type ButtonProps = {
  variant: 'primary' | 'secondary'
}

export const CustomButton = ({ variant, children, ...rest }: ButtonProps) => {
  return <button className={`class-with-${variant}`} {...rest}>{children}</button>
}

다음 코드는 에러가 발생한다. props로 넘어오는 children과 rest에 대한 타입 정의가 이루어지지 않았기 때문이다.

type ButtonProps = {
  variant: 'primary' | 'secondary'
  children: React.ReactNode
  onClick: () => void
}

위에서 발생하는 문제를 해결하는 가장 간편한 해결방법이다.
onClick 함수는 HTMLButtonElement에 존재하기 때문에 어떻게 가져와서 사용할 수 없을까? 라는 생각은 들지만 굳이 해보고 싶지는 않다.

그렇다면 다음과 같은 경우는 어떨까?

type ButtonProps = {
  variant: 'primary' | 'secondary'
  children: React.ReactNode
  onClick: () => void
  name:
  tabIndex:
  value:
  disabled:
  autofocus:
}

variant, children 빼고는 모두 HTMLButtonElement에 존재하는 프로퍼티들이다. 이쯤 되면 HTMLButtonElement를 가져와서 사용해야겠다라는 생각이 든다. 그 사용 방법은 다음과 같다.

// type을 사용하는 경우
type ButtonProps = {
  variant: 'primary' | 'secondary'
} & React.ComponentProps<'button'>

// interface를 사용하는 경우
interface ButtonProps extends React.ComponentProps<'button'> {
  variant: 'primary' | 'secondary'
}

React.ComponentProps에서 children에 대한 타입 또한 처리해준다. (React.ReactNode 타입으로)


가끔 다음과 같은 방법을 사용한 코드들을 볼 수 있다.

interface ButtonProp extends React.HTMLAttributes<HTMLButtonElement> {
  variant: 'primary' | 'secondary'
}

위에서 제시한 React.ComponentProps와 React.HTMLAttributes<>는 동일한 결과물을 도출할까?

아니다. 둘은 완전히 다른 결과물을 만들어낸다. 이를 확인하기 위해 HTMLAttributes를 살펴본다.

interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
  // React-specific Attributes
  defaultChecked?: boolean | undefined;
  defaultValue?: string | number | string[] | undefined;
  suppressContentEditableWarning?: boolean | undefined;
  suppressHydrationWarning?: boolean | undefined;

  // Standard HTML Attributes
  accessKey?: string | undefined;
  accesskey?: string | undefined;
  className?: string | undefined;
  class?: string | undefined;
  contentEditable?: Booleanish | 'inherit' | undefined;
  contenteditable?: Booleanish | 'inherit' | undefined;
  contextMenu?: string | undefined;
  contextmenu?: string | undefined;
  dir?: string | undefined;
  draggable?: Booleanish | undefined;
  hidden?: boolean | string | undefined;
  id?: string | undefined;
  lang?: string | undefined;
  placeholder?: string | undefined;
  slot?: string | undefined;
  spellCheck?: Booleanish | undefined;
  spellcheck?: Booleanish | undefined;
  style?: CSSProperties | undefined;
  tabIndex?: number | undefined;
  tabindex?: number | string | undefined;
  title?: string | undefined;
  translate?: 'yes' | 'no' | undefined;

  // Unknown
  radioGroup?: string | undefined; // <command>, <menuitem>
  radiogroup?: string | undefined;

  // WAI-ARIA
  role?: string | undefined;

  // RDFa Attributes
  about?: string | undefined;
  datatype?: string | undefined;
  inlist?: any;
  prefix?: string | undefined;
  property?: string | undefined;
  resource?: string | undefined;
  typeof?: string | undefined;
  vocab?: string | undefined;

  // Non-standard Attributes
  autoCapitalize?: string | undefined;
  autocapitalize?: string | undefined;
  autoCorrect?: string | undefined;
  autocorrect?: string | undefined;
  autoSave?: string | undefined;
  autosave?: string | undefined;
  color?: string | undefined;
  itemProp?: string | undefined;
  itemprop?: string | undefined;
  itemScope?: boolean | undefined;
  itemscope?: boolean | string | undefined;
  itemType?: string | undefined;
  itemtype?: string | undefined;
  itemID?: string | undefined;
  itemid?: string | undefined;
  itemRef?: string | undefined;
  itemref?: string | undefined;
  results?: number | string | undefined;
  security?: string | undefined;
  unselectable?: 'on' | 'off' | undefined;

  // Living Standard
  /**
   * Hints at the type of data that might be entered by the user while editing the element or its contents
   * @see https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute
   */
  inputMode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search' | undefined;
  inputmode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search' | undefined;
  /**
   * Specify that a standard HTML element should behave like a defined custom built-in element
   * @see https://html.spec.whatwg.org/multipage/custom-elements.html#attr-is
   */
  is?: string | undefined;
}

제네릭 인자 T로 넘어오는 HTMLElement가 내부적으로 전혀 사용되지 않는다. ( DOMAttributes<T> 는 이벤트 핸들러만을 처리하기 때문에 상관없다고 한다. 근데 이벤트 핸들러를 처리하는 DomAttributes가 extend 됐으면 onClick과 같은 이벤트 함수들도 처리해줘야 하는거 아닌가...? 왜 처리하지 못해서 에러가 나지...? 이거는 추후에 더 찾아보고 추가해야겠다.) 때문에 특정한 HTMLElement를 인자로 넘긴다고 해서 그 HTMLElement가 가지는 프로퍼티들이 상속되는 것이 아니라 위에 정의되어 있는 프로퍼티만이 상속 된다. 특히 이벤트 핸들러 함수들이 상속되지 않는다는 점이 중요하다.


유의해야 하는 지점

React.ComponentProps는 기본적으로 children의 타입을 React.ReactNode로 지정한다. 만약 children이 React.ReactNode가 아닌 string으로만 넘어온다면 어떻게 타입을 정의해야할까?

import React from 'react'

type ButtonProps = {
  variant: 'primary' | 'secondary'
  children: string
} & React.ComponentProps<'button'>

export const CustomButton = ({ variant, children, ...rest}: ButtonProps) => {
  return (
  	<button>...</button>
  )
}

이렇게 코드를 작성하면 되는걸까?

이렇게 코드를 작성하게 되면 children의 타입은 string & React.ReactNode가 된다. children의 타입을 string으로만 한정하려는 것이 목표였지만 이 코드로는 해당 기능을 수행할 수 없다.

이럴 때 사용하는 것이 Omit이다. Omit의 2번째 인자로 제거할 인자의 이름을 넘겨준다.

type ButtonProps = {
  variant: 'primary' | 'secondary'
  children: string
} & Omit<React.ComponentProps<'button'>, 'children'>

Omit 이외에도 Pick이나 Exclude 와 같은 유틸리티 타입들을 잘 사용하면 타입을 최대한 좁혀서 선언할 수 있다.

<CustomButton>이거는 children의 typestring</CustomButton>

<CustomButton><div>이거는 children의 type의 ReactNode</div></CustomButton>

또한, React.ComponentProps의 인자는 HTMLElement만 들어갈 수 있는 것이 아니다. 우리가 만든 리액트 컴포넌트 또한 인자로 들어갈 수 있다.

// Greet.tsx
type GreetProps = {
  name: string
  messageCount?: number
  isLoggedIn: boolean
}

export const Greet = (props: GreetProps) => {
  // ...
}

// CustomComponent.tsx - (1)
import { Greet } from './Greet'

export const CustomComponent = (props: React.ComponentProps<typeof Greet>) => {}
                                 
// CustomComponent.tsx - (2)
export const CustomComponent = (props: { name: string, messageCount?: number, isLoggedIn: boolean }) => {}

(1)과 (2)는 동일한 결과를 가지게 된다.


고민한 지점

type ButtonProps = {} & React.ComponentProps<{HTMLElement}>

과연 이러한 형태가 좋다고만 말할 수 있을까? 특정한 HTMLElement의 프로퍼티는 굉장히 많은데 사용하지 않는 프로퍼티들에 대한 타입까지 상속받는 것은 위험한 일 아닐까?

무엇이 정답이다라고 말할 수는 없지만 보통 함수의 인자 타입은 넓게, 반환 타입은 좁게 선언하는 것이 바람직하다고 한다. 또한, 컴포넌트를 작성할 때, 특히 공통 컴포넌트를 작성할 때는 어떠한 인자들이 넘어 올 지 쉽게 결정할 수 없다. 때문에 확실히 사용하는 인자들만 명시하고 나머지는 rest operation으로 받는 경우가 많다. 이런 경우 최대한 타입을 넓게 두는 것이 해당 컴포넌트의 재사용 측면에서 유리할 수 있다.


참고문헌

https://dev.indooroutdoor.io/how-to-extend-any-html-element-with-react-and-typescript

https://www.youtube.com/watch?v=uZ8GZm5KEXY&list=PLC3y8-rFHvwi1AXijGTKM0BKtHzVC-LSK&index=24

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/78120d1d515a1cd7bcd3145424b1a17be15310a9/types/babel-plugin-react-html-attrs/index.d.ts#L1742

좋은 웹페이지 즐겨찾기