[TypeScript] 제네릭으로 Button 컴포넌트 타입 안정성 높이기

[TypeScript] 제네릭으로 Button 컴포넌트 타입 안정성 높이기

·

3 min read

YEONSUI 버전 2의 네 번째 작업은 버튼 컴포넌트를 정의하는 것이다.

피그마로 버튼 컴포넌트를 미리 디자인하였다. 스타일은 다음과 같다.

StyleVariant
filledprimary
outlinedprimary, secondary, tertiary
ghostprimary, secondary
iconprimary, secondary, filled outlined

각 Style마다 다른 Variant 옵션을 갖고 있다.

이제 버튼 컴포넌트를 정의하는데 필요한 props 타입을 정의하고 사용해보자.

type ButtonStyle = 'filled' | 'outlined' | 'ghost' | 'icon'
type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'filled' | 'outlined'

export interface ButtonProps extends HTMLAttributes<HTMLButtonElement> {
  styleType: ButtonStyle
  variant?: ButtonVariant
  ...
}
export const ButtonExample = () => (
  <>
    <Button styleType="filled" variant="primary">Type ⭕️</Button>
    <Button styleType="filled" variant="secondary">Type ❌</Button>
    <Button styleType="outlined" variant="filled">Type ❌</Button>
  </>
)

여기서 한 가지 문제를 발견하였다. styleType 값과 매치하지 않은 variant 값이 함께 사용되어도 에러가 발생하지 않는 것이다. filled style은 secondary variant를 갖고 있지 않고, outlined style은 filled variant를 갖고 있지 않다.

이것을 해결하기 위해 styleType 값에 따라 variant 타입이 바뀌도록 수정해야 한다.

타입스크립트 개념을 공부하면서 도대체 언제 이걸 쓰지? 의문이 들었던 이것을 드디어 쓸 날이 왔다. 바로, 제네릭이다.

제네릭(Generics)이란?

타입스크립트에서 함수, 클래스, 인터페이스 등을 작성할 때 타입을 미리 지정하지 않고, 사용할 때 지정할 수 있게 해주는 기능이다. 제네릭을 사용하면 다양한 타입에서 재사용할 수 있는 컴포넌트나 함수 등을 작성할 수 있어 코드의 유연성과 타입 안전성을 높일 수 있다.

주로 <> 안에 파라미터를 정의하여 사용한다.

1. 각 Style에 대한 Variant 타입 정의하기

type ButtonStyle = 'filled' | 'outlined' | 'ghost' | 'icon'

type ButtonFilledVariant = 'primary'
type ButtonOutlinedVariant = 'primary' | 'secondary' | 'tertiary'
type ButtonGhostVariant = 'primary' | 'secondary'
type ButtonIconVariant = 'primary' | 'secondary' | 'filled' | 'outlined'

우선, 버튼의 Style과 그에 대한 각 Variant 타입을 정의한다.

2. 제네릭 타입 매핑

type ButtonVariant<T> = T extends 'filled'
  ? ButtonFilledVariant
  : T extends 'outlined'
  ? ButtonOutlinedVariant
  : T extends 'ghost'
  ? ButtonGhostVariant
  : T extends 'icon'
  ? ButtonIconVariant
  : never

제네릭을 사용해 Style에 따라 변형을 매핑하는 타입을 정의한다.

조건부 타입이 T extends U ? X : Y 라면 *"T가 U에 할당 가능하면 X 타입을, 그렇지 않다면 Y 타입 반환"*을 뜻한다.

T가 filled라면 ButtonFilledVariant, outlined라면 ButtonOutlinedVariant, ..., 아무것도 아니라면 never 타입이 ButtonVariant 타입이 된다.

3. ButtonProps 타입 정의

export interface ButtonProps<Style extends ButtonStyle> extends HTMLAttributes<HTMLButtonElement> {
  styleType: Style
  variant?: ButtonVariant<Style>
}

제네릭으로 ButtonStyle 타입을 지닌 Style 파라미터를 받는다. styleTypeStyle 타입을 가지고, 이것은 ButtonStyle 중 하나이다.

variantButtonVariant 타입을 통해 Style에 따라 적절한 변형 타입을 반환한다.

4. Button 컴포넌트 적용

export const Button = <Style extends ButtonStyle>({
  styleType,
  variant = 'primary',
}: ButtonProps<Style>) => {
  ...
}

Button 컴포넌트는 styleType을 필수로 받고, variantstyleType에 따라 변형된 타입 내에서 자유롭게 받는다.

처음에 다뤘던 코드를 다시 살펴보면 내가 원하던 타입 에러가 발생했다.

그런데 한 가지 문제가 또 있다. 아래 코드에서 variant에 primary 타입이 존재하지 않다는 타입 에러가 발생한 것이다.

export const Button = <Style extends ButtonStyle>({
  styleType,
  variant = 'primary',
}: ButtonProps<Style>) => {
  ...
}

이것은 제네릭에 아무런 값이 없기 때문에 variant 타입을 추론할 수 없기 때문이다.

이럴 땐 variant 타입을 필수 값으로 지정해도 되지만, 나는 그냥 아래와 같이 primary 타입을 추가하였다. 어차피 모든 버튼 Style에서 primary Variant를 갖고 있기 때문에 문제되지 않는다.

export interface ButtonProps<Style extends ButtonStyle> extends HTMLAttributes<HTMLButtonElement> {
  styleType: Style
  variant?: ButtonVariant<Style> | 'primary'
}