확장성 있는 Text 컴포넌트

1
2
<Text typography="h1">나는 머릿말 1이야</Text>
<Text typography="content">나는 콘텐츠야</Text>

위와 같은 컴포넌트를 만들기 위한 작업을 시작해보자.

컴포넌트 재사용을 높이기 위해서는 누구나 이해하기 쉽게 작성하는 것이 좋다. 그러기 위해서는 선언형 코드인 HTML 방식을 따르는 것이 이득이다.

HTML 처럼 만든 컴포넌트

1
2
3
4
type Props = HTMLAttributes<"span">;
const Text = (props: Props) => {
/* ... */
};

이런식으로만 사용해도 HTML Element를 똑같이 따라할 수 있다.

하지만 HTML 속성은 ref, key 같은 리액트 속성들을 없다.

이 때 사용하는 것이 forwardRef 함수로 ref 값은 전달해준다.

1
2
3
4
5
6
const Text = forwardRef(function Text(
props: ComponentPropsWithoutRef<"span">,
ref: Ref<HTMLSpanElement>
) {
return <span ref={ref}>{props.children}</span>;
});
  • ComponentPropsWithoutRef<"span">은 ref만 제외한 나머지 리액트 프로퍼티를 모두 포함한다.

커스텀 프로퍼티 추가하기

지금까지의 컴포넌트는 그저 span element와 동일하다. 다른 커스텀 프로퍼티도 받을 수 있도록 타입스크립트의 & (intersection)을 사용하여 타입을 확장해보자.

1
2
3
4
5
6
7
8
9
10
type TextProps = {
typography?: string;
} & ComponentPropsWithoutRef<"span">;

const Text = forwardRef(function Text(
{ typography, ...props }: Props,
ref: Ref<HTMLSpanElement>
) {
//...
});

하지만, ComponentPropsWithoutRef<"span">가 이미 갖고 있는 프로퍼티와 커스텀 프로퍼티가 동일한 경우 대응하기 어렵다.

1
2
3
type TextProps = {
customId?: number;
} & ComponentPropsWithoutRef<"span">;

예를 들어 위와 같은 커스텀 프로퍼티는 ComponentPropsWithoutRef<"span">와 동일한 프로퍼티가 없기 때문에 제대로 타입이 추론된다.

1
2
3
type TextProps = {
id?: number;
} & ComponentPropsWithoutRef<"span">;

하지만 id 프로퍼티는 ComponentPropsWithoutRef<"span">가 소유하고 있어 동일한 프로퍼티 2개가 생기게 되므로 타입스크립트가 제대로 추론하지 못하고 undefined로 추론된다.

이를 해결하기 위해서는 Omit 타입을 사용하여 오버라이딩하려는 프로퍼티를 먼저 제거한 후 병합해야지만 문제가 없다.

1
type Combine<T, K> = T & Omit<K, keyof T>;
  • Combine 유틸타입은 2개의 타입을 받은 후 K타입에서 T 타입이 가진 프로퍼티와 중복되는 프로퍼티를 제거한 후 & (intersection)으로 병합한다.

이런식으로 타입을 병합하려면 상당히 귀찮기 때문에 유틸 타입을 하나 만들어둔다.

원하는 요소로 렌더링 하기

지금까지는 span 요소로만 작동하기 때문에 확장성을 위해 ComponentPropsWithoutRef<"span"> 에서 span 위치에 변수를 할당하도록 구현한다.

1
2
3
4
5
6
7
8
9
// Text 컴포넌트의 커스텀 프로퍼티 선언
type TextBaseProps<T> = {
typography?: string;
as?: T;
};

// Props<T>는 ComponentPropsWithoutRef<T>에 이 값을 그대로 넘겨준다.
// 그리고 커스텀 프로퍼티 내부의 as에도 T 타입을 바인딩해준다.
type TextProps<T> = Combine<TextBaseProps<T>, ComponentPropsWithoutRef<T>>;

하지만, 이는 오류를 발생시킨다. ComponentPropsWithoutRef가 받을 수 있는 제네릭 타입이 ElementType으로 정해져 있기 때문이다.

그래서 type TElementType을 상속한 타입이여야 한다고 명시해야한다.

1
2
3
4
5
6
7
8
9
10
11
type TextBaseProps<T extends ElementType> = {
typography?: string;
as?: T;
};

type TextProps<T extends ElementType> = Combine<
TextBaseProps<T>,
ComponentPropsWithoutRef<T>
>;

function Text<T extends ElementType>(props: TextProps<T>) {}

이제 모든 type T가 동일하다는 것을 보장할 수 있다.

  1. Text<T>의 타입 변수 T
  2. TextProps<T>의 타입 변수 T
  3. TextBaseProps<T>의 타입 변수 T
  4. as 프로퍼티에 바인딩 된 타입 변수 T
  5. ComponentPropsWithoutRef<T>의 타입 변수 T

즉, 이 중 한곳이라도 T에 대해서 명확하게 알 수 있다면 나머지 부분에서도 자연스럽게 추론이 가능하다.

as 프로퍼티로 타이핑 추상화 하기

as라는 프로퍼티는 Text 컴포넌트 뿐만 아니라 다양한 컴포넌트에서도 사용될 수 있기에 이 부분을 최대한 추상화 해둘 필요가 있다.

1
2
3
4
5
6
7
8
// 텍스트 컴포넌트의 프로퍼티
type TextBaseProps = {
typography?: string;
};

// T 타입을 추론할 수 있는 as 프로퍼티를 자동으로 포함하고
// T 타입으로 HTML 엘리먼트 속성까지 타이핑 해주는 OverridableProps!
type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;
  • OverridableProps 타입은 특정 컴포넌트가 as 프로퍼티를 사용하여 HTML 요소 이름을 받고 내부적으로 해당 요소의 속성 타입을 찾아 바인딩해주는 함수이다.

이렇게 필요한 부분을 추상화해두면 필자가 아닌 다른 개발자는 ComponentPropsWithoutRef을 사용해야한다던가 Combine 타입을 사용할 때 타입 변수 TElementType으로 제한해야한다던가 하는 귀찮은 부분을 생각하지 않고도 as 프로퍼티를 쉽고 빠르게 추가할 수 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 전체 코드
export type Combine<T, K> = T & Omit<K, keyof T>;

export type CombineElementProps<T extends ElementType, K = unknown> = Combine<
K,
ComponentPropsWithoutRef<T>
>;

type OverridableProps<T extends ElementType, K = unknown> = {
as?: T;
} & CombineElementProps<T, K>;

type TextBaseProps = {
typography?: string;
};

type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;

function Text<T extends ElementType = "span">(
{ typography = "content", as, ...props }: TextProps<T>,
ref: Ref<any>
) {
const target = as ?? "span";
const Component = target;

return (
<Component
ref={ref}
// 대충 타이포그래피 클래스 렌더하는 로직
{...props}
/>
);
}

export default forwardRef(Text) as typeof Text;

참조

Evans Library Blog - “타입스크립트와 함께 컴포넌트 단계별로 추상화하기”