ReactNode 슬롯은 ListRow에 들어올 컴포넌트를 제한하지 못했다

컴포넌트의 정체성을 검사하는 대신 정해진 데이터만 받기까지

앞 글은 공개 Recipe와 내부 Compound Primitive로 조합의 층을 제한했다. 그런데 마지막에 쓴 호출부에는 아직 열린 자리가 하나 남아 있었다.

tsx
<ListRowBasic
  variant="count"
  leading={<ListIcon name="bell" />}
  label="전체 알림"
  count={12}
/>

leading이 그 자리다. Recipe를 골라도 저 slot에는 여전히 아무 컴포넌트나 들어갈 수 있고, ListRowBasic만의 사정도 아니다. 아래에서는 Leading이 계약의 중심에 있는 ListRowRegular를 놓고 본다.

leading={<ListIcon name="user" />}은 자연스러운 React API처럼 보였다. 무엇이 화면에 렌더링되는지 JSX에 그대로 드러나고, 아이콘이 필요하면 <ListIcon />을, 이미지가 필요하면 <ListImage />를 넣으면 된다.

문제는 같은 자리에 디자인 시스템이 제공하지 않은 컴포넌트도 들어갈 수 있다는 점이었다.

tsx
<ListRowRegular leading={<CustomIcon size={48} />} />

화면은 멀쩡히 그려진다. 하지만 Figma에서 정한 크기와 다른 아이콘, 토큰을 거치지 않은 색상, 승인되지 않은 자산이 List 안에 들어올 수 있다.

slot으로 React element를 받는 순간, Leading에 무엇을 그릴지는 다시 호출부의 결정이 된다. 앞 글에서 공개 API의 디자인 결정을 걷어냈다고 적었는데, 그 결정이 이 한 자리로 되돌아오고 있었다.

Leading에는 승인된 아이콘만 받고 싶었다

List의 Leading에는 크게 아이콘과 이미지가 들어갔다. 둘은 시각적으로 같은 위치에 있지만 소유권은 달랐고, 1막에서 세운 첫 번째 질문을 그대로 물으면 갈리는 자리가 보인다. 이 결정에 필요한 정보를 누가 갖고 있는가.

아이콘은 디자인 시스템의 자산이었다. 쓸 수 있는 이름, 크기, 색상, stroke, Content와의 간격을 열거할 수 있었다. 제품이 아는 것은 어떤 뜻의 아이콘이 필요한가까지이고, 그 뜻을 어떤 그림으로 그릴지는 시스템이 이미 정해뒀다.

반면 프로필 사진이나 썸네일은 제품의 콘텐츠였다. 디자인 시스템이 가능한 이미지 URL을 미리 알 수는 없다. 크기, 비율, border radius, object-fit 같은 표현 규칙만 Recipe가 정할 수 있었다.

아이콘
디자인 시스템의 자산
name시스템
size시스템
color시스템
stroke시스템
gap시스템
전부 열거할 수 있다
name: IconName
이미지
제품의 콘텐츠
src제품
size시스템
ratio시스템
radius시스템
object-fit시스템
맨 위 한 칸만 열린다
src: string
아이콘은 다섯 값이 전부 시스템 쪽에 있고, 이미지는 맨 위 한 칸만 제품 쪽이다. 그 한 칸이 아래 결론을 닫힘과 열림으로 가른다

따라서 공개 API는 다음을 구분해야 했다.

  • 아이콘은 승인된 자산만 선택할 수 있어야 한다.
  • 이미지에는 외부 URL을 전달할 수 있어야 한다.
  • 아이콘과 이미지를 동시에 넣을 수 없어야 한다.
  • 호출부에서 아이콘 크기와 색상을 다시 정할 수 없어야 한다.
  • 접근 가능한 이름이 필요한 경우와 장식으로 처리할 경우를 구분할 수 있어야 한다.

이 조건을 놓고 Leading을 받는 방법을 비교했다.

방식 사용 형태 제한할 수 있는 범위
element slot leading={<ListIcon />} 임의 JSX가 들어온다
컴포넌트 참조 leading={ListIcon} props 모양을 제한한다. 출처를 막으려면 표시가 더 필요하다
개별 prop leadingIcon="user" 형태가 단순할 때 강하게 제한한다
값이 정해진 descriptor leading={{ type: 'icon', name: 'user' }} 형태와 값의 관계를 함께 제한한다
element를 받는다
문을 지나면 둘이 같아진다
<ListIcon name="user" />
<CustomIcon size={48} />
leading: ReactNode
JSX element
type: any
JSX element
type: any
둘 다 통과한다
데이터를 받는다
문 앞에서 갈린다
{ type: 'icon', name: 'user' }
<CustomIcon size={48} />× 문 앞에서 막힌다
leading: LeadingSpec
계정
Recipe가 크기·색상·간격을 정했다
문을 지난 값만 그려진다
왼쪽은 문을 지나면 둘이 같은 element가 되어 구별할 수 없다. 오른쪽은 문 앞에서 갈려서, 통과한 값만 Recipe가 승인된 모양으로 그린다

같은 props를 가진 CustomIcon도 타입을 통과했다

element 대신 컴포넌트 참조를 받으면 임의의 <div>는 막을 수 있다.

tsx
<ListRowRegular leading={ListIcon} />

타입도 간단하다.

typescript
type ListRowRegularProps = {
  leading: ComponentType<IconProps>;
};

그런데 이 타입이 확인하는 것은 컴포넌트의 출처가 아니라 props의 모양이다.

TypeScript는 구조적 타입 시스템이다. 서로 다른 컴포넌트라도 같은 props 시그니처를 가지면 호환될 수 있다.

tsx
declare function CustomIcon(props: IconProps): ReactElement;

<ListRowRegular leading={CustomIcon} />;
// 같은 props 시그니처를 가지므로 통과할 수 있다.

ComponentType 대신 typeof ListIcon으로 좁혀도 결과는 같다. 함수 타입의 구조가 같으면 여전히 호환된다.

typescript
type ListRowRegularProps = {
  leading: typeof ListIcon;
};

이 API에서는 Figma에 없는 아이콘, 다른 stroke를 쓰는 아이콘, 디자인 토큰을 거치지 않은 아이콘도 Leading으로 들어왔다. 컴포넌트를 전달하는 순간 호출부는 어떤 데이터를 보여줄지뿐 아니라 어떻게 그릴지까지 결정한다. 디자인 시스템이 쥐고 있어야 했던 것이 그 두 번째다.

명목적 타입이었다면 막혔을까

CustomIcon이 통과한 것은 TypeScript가 무언가를 놓쳐서가 아니다. 이름이나 선언 위치가 아니라 모양을 보는 것이 구조적 타입 시스템의 규칙이고, 그 규칙이 제대로 작동한 결과다.

반대편에는 명목적 타입이 있다. Rust로 같은 제약을 쓰면 이렇게 된다.

rust
trait ApprovedIcon {}

impl ApprovedIcon for ListIcon {}

fn leading<I: ApprovedIcon>(icon: I) { /* ... */ }

CustomIcon은 모양이 아무리 같아도 들어오지 못한다. impl ApprovedIcon for CustomIcon이라는 선언이 없기 때문이다. trait 구현은 선언으로만 생기지 우연히 생기지 않는다.

이것도 저절로 막히지는 않는다. orphan rule이 남의 trait을 남의 타입에 붙이는 것은 막지만, 다른 크레이트가 자기 타입에 공개 trait을 구현하는 것은 막지 않는다. 그것까지 막으려면 sealed trait을 따로 쓴다. 언어가 명목적이라는 사실만으로는 부족하고 막겠다는 의도를 한 번 더 표현해야 한다.

승인 표시를 컴포넌트에 붙여봤다

TypeScript에도 그 의도를 표현하는 관용구가 있다. 브랜드다. 어디에 붙이느냐가 중요했다. 구분하려는 것은 IconProps의 정체성이 아니라 어떤 컴포넌트가 승인된 아이콘인가다. props에 필수 브랜드를 심으면 <ListIcon name="user" />이라는 평범한 호출부터 브랜드 값을 공급하지 못해 깨진다. 붙일 자리는 컴포넌트 쪽이다.

typescript
declare const approvedIcon: unique symbol;

type ApprovedIconComponent = ComponentType<IconProps> & {
  readonly [approvedIcon]: true;
};

const ListIcon = ListIconBase as ApprovedIconComponent;

unique symbol로 선언한 키는 그 선언 하나뿐이라 바깥에서 같은 모양을 만들 수 없다. 모양을 보는 규칙은 그대로 두고 흉내낼 수 없는 표시를 하나 심는 방식이다.

이건 실제로 동작했다. leading: ApprovedIconComponent로 선언한 slot은 CustomIcon을 거부하고, 브랜드가 붙은 ListIcon은 그대로 <ListIcon name="user" />으로 렌더된다. TypeScript가 명목적 제약을 표현하지 못하는 것은 아니다.

다만 이 표시는 감싸는 순간 떨어진다.

tsx
const MemoListIcon = memo(ListIcon);

<ListRowRegular leading={MemoListIcon} />;
// ApprovedIconComponent로 좁힌 slot에는 들어가지 않는다.

memo가 돌려주는 것은 원래 컴포넌트를 감싼 다른 타입이고, 브랜드는 거기까지 따라가지 않는다. typeof ListIcon으로 좁힌 slot에서도 같은 결과다.

표시를 붙이는 변환
ListIconBase
표시 없음
as ApprovedIconComponent
ListIcon
[approvedIcon]
통과
감싸는 변환
ListIcon
[approvedIcon]
memo()
MemoListIcon
표시 없음
× 거부
판정하는 slot은 양쪽 다 leading: ApprovedIconComponent
두 줄의 차이는 가운데 한 칸뿐이다. 표시를 붙이는 변환에서는 표시가 살아남고, 감싸는 변환에서는 그 자리가 빈다

명목적 제약이 통하려면 두 가지가 필요하다. 언어가 그것을 표현할 수 있어야 하고, 그 표시가 값이 지나가는 경로 끝까지 남아 있어야 한다. 컴포넌트 참조를 그대로 넘기는 동안에는 둘 다 성립했다. 그런데 우리가 쓰고 싶었던 문법은 참조를 넘기는 것이 아니었다.

JSX element의 정적 타입으로는 정체성을 좁히기 어려웠다

<ListIcon />을 직접 넣는 문법이 가장 자연스러웠다. 그래서 JSX를 유지하면서 타입만 좁힐 수 있는지도 확인했다. ReactElement는 props와 element의 type을 타입 인자로 받으니 둘을 함께 지정하면 될 것 같았다.

typescript
type ListRowRegularProps = {
  leading: ReactElement<IconProps, typeof ListIcon>;
};

하지만 내가 확인한 타입 정의와 컴파일 환경에서는 JSX 표현식이 그만큼 구체적인 정체성을 남기지 않았다. JSX.ElementReactElement<any, any>로 선언돼 있어서 <ListIcon name="user" /><CustomIcon name="user" />이 호출부에서 같은 타입으로 다뤄졌고, propstypeany인 element는 더 좁게 선언한 slot에도 들어간다.

앞 절의 브랜드를 element 타입 인자에 넣어 ReactElement<IconProps, ApprovedIconComponent>로 좁혀도 결과는 같았다. 그 자리에 실제로 들어오는 것은 any이고, any는 무엇에든 할당된다. 브랜드는 컴포넌트 선언 쪽에 그대로 있고 런타임 element에도 type으로 실려 있다. 공개 slot의 정적 타입이 그 둘을 이어주지 못할 뿐이다.

실제로 비교한 결과는 이렇다.

slot 타입 승인된 아이콘 임의 컴포넌트 임의 마크업
ReactNode 통과 통과 통과
ReactElement<IconProps> 통과 통과 통과
ReactElement<IconProps, typeof ListIcon> 통과 통과 통과
ReactElement<IconProps, ApprovedIconComponent> 통과 통과 통과
컴포넌트 참조 typeof ListIcon 통과 통과 타입 오류
값이 정해진 descriptor LeadingSpec 통과 타입 오류 타입 오류

아래 두 행에서 열 이름은 문법이 아니라 호출부가 그 자리에 넣으려는 것을 가리킨다. 컴포넌트 참조 행의 승인된 아이콘은 ListIcon이고, descriptor 행에서는 { type: 'icon', name: 'user' }다.

모든 React·TypeScript 버전에서 그렇다고 말할 수는 없다. 당시 쓰던 환경에서 JSX slot을 공개하면서 승인된 컴포넌트만 컴파일 단계에서 허용한다는 계약을 만들 수 없었다는 것까지가 확인한 범위다.

React가 최적화하는 것과 디자인 시스템이 지키려는 것이 달랐다

여기서 결론을 타입 시스템이 부족하다는 쪽으로 내리면 방향이 틀어진다. 브랜드는 컴포넌트 참조에서 분명히 통했다. 걸린 것은 더 좁은 자리다. element를 만든 컴포넌트가 무엇인지를 공개 slot의 정적 계약에 남기기가 어려웠고, 그것도 당시 쓰던 React·TypeScript의 JSX 타입 모델에서 그랬다.

이유는 element라는 값의 성격에 있다. element는 어디서 만들었든 값으로 다뤄지고 어디로든 전달되며 다른 함수로 감싸일 수 있다. 그 자유가 React의 조합 모델이다.

정체성을 타입에 정밀하게 남기려 할수록 멀쩡한 React 관용구가 먼저 걸렸다. 앞에서 본 memo가 그랬고, 조건부 렌더도 그렇다.

tsx
<ListRowRegular leading={condition && <ListIcon name="user" />} />;
// 조건부 렌더의 타입은 false | JSX.Element다.

조건에 따라 Leading을 비우는 흔한 표현인데, element로 좁힌 slot에는 false가 들어가지 않는다.

둘 다 잘못 쓴 코드가 아니다. React는 조합을 쉽게 만드는 쪽으로 설계됐고, 디자인 시스템은 승인된 조합을 우선한다. 방향이 다르다. 한쪽의 목표를 다른 쪽의 도구로 이루려니 계속 어긋났다.

브랜드 붙인 컴포넌트 참조가 타입으로 통한다 해도, 그것이 이 API가 받아야 할 입력이었는지는 별개의 문제다. 제품 코드가 아는 것은 user라는 승인된 값이지 그 값을 그리는 컴포넌트가 아니다. 컴포넌트를 받으면 호출부는 여전히 무엇으로 그릴지 고르는 자리에 선다.

JSX를 그대로 두고 다른 층에서 검사하는 방법도 있다.

  • 개발 환경에서 leading.type을 확인하고 경고한다.
  • ESLint custom rule로 허용된 컴포넌트인지 검사한다.
  • 코드 리뷰와 Storybook에서 잘못된 사용을 찾는다.

전부 유효하다. 다만 제품 개발자의 에디터에서 즉시 타입 오류를 보여주지는 않으니, 잘못된 조합을 타입 검사 단계에서 발견하게 한다는 목표와는 거리가 있었다.

그래서 질문을 바꿨다. 이 자리에 들어온 React element가 승인된 것인지 검사하는 대신, 검사할 대상 자체를 바꾸기로 했다.

그래서 컴포넌트를 받지 않기로 했다

Leading을 React element가 아닌 descriptor로 받았다.

typescript
type IconName = 'search' | 'user' | 'setting';

type LeadingSpec =
  | { type: 'icon'; name: IconName; label?: string }
  | { type: 'image'; src: string; alt: string };

Leading이 없는 경우는 { type: 'none' } 브랜치가 아니라 leading?: LeadingSpec으로 뒀다. 판별자 하나만 요구하는 브랜치를 union에 두면 방금 막은 것이 그 브랜치로 되돌아온다. React element의 typeany라서 그런 브랜치에 구조적으로 들어맞고, <CustomIcon />을 넘기면 none으로 읽히며 타입 검사를 지나간다.

판별자 말고도 요구하는 값이 있는 브랜치만 남기면 그 경로가 막힌다.

제품 코드에서는 다음처럼 사용한다.

tsx
<ListRowRegular leading={{ type: 'icon', name: 'user' }} label="계정" />

호출부는 user 아이콘을 보여달라고 요청하고, 실제 아이콘 컴포넌트와 크기, 색상, 간격은 Recipe가 결정한다.

tsx
function renderLeading(spec: LeadingSpec | undefined) {
  if (!spec) return null;

  switch (spec.type) {
    case 'icon':
      return (
        <Icon name={spec.name} size="md" color="icon.default"
          aria-label={spec.label}
          aria-hidden={spec.label === undefined} />
      );

    case 'image':
      return (
        <Image src={spec.src} alt={spec.alt}
          size="md" radius="sm" fit="cover" />
      );
  }
}

받는 것이 element에서 데이터로 바뀌자 “이 자리에 온 것이 승인된 컴포넌트인가”를 물을 일이 없어졌다. 승인 여부는 IconName에 있는 이름인가로 옮겨갔다.

아이콘은 이름으로 제한하고 이미지는 URL로 열었다

descriptor로 바꾸기로 한 것과 그 안에서 무엇을 제한할지는 다른 결정이다. 첫 절에서 아이콘과 이미지의 소유권이 다르다고 판정했는데, 그 판정이 실제로 어떤 타입이 되는지가 여기서 갈린다. 질문 1을 이번에는 API 형태에 대고 다시 묻는다.

IconName을 union으로 제한한 것이 중요했다. 아이콘도 src: string으로 받으면 임의 컴포넌트를 받는 문제를 자산 경로 수준에서 반복하게 된다. 첫 절에서 센 다섯 값이 전부 시스템 쪽에 있었으니 이름으로 열거하면 된다.

image branch는 src 한 칸만 열고 크기, 비율, radius, object-fit은 Recipe 안에 남겼다. 첫 절의 표에서 제품 쪽에 있던 칸이 정확히 그 하나였다. 열 자리와 닫을 자리를 자산의 종류가 정하지 않았다. 그 값을 누가 아는지가 정했다.

아이콘 쪽에서는 승인 목록 밖의 값도, 호출부가 다시 정하려는 표현도 인라인 객체에서 타입 오류가 된다.

tsx
<ListRowRegular leading={{
  type: 'icon',
  name: 'custom-thing', // IconName에 없다
  size: 40,             // LeadingSpec에 없다
}} />

아이콘의 label은 비어 있지 않은 문자열을 강제하면 되는 문제가 아니었다. 옆의 Content가 이미 같은 정보를 전달한다면 그 자산은 장식이고, 중복된 이름을 다시 읽히게 하는 것보다 접근성 트리에서 빼는 편이 낫다.

그래서 label은 optional로 두되 부재에 뜻을 실었다. label이 없으면 장식용 자산으로 보고 접근성 트리에서 제외한다.renderLeadingaria-hidden이 그 계약이다. 무엇을 정보로 읽고 무엇을 장식으로 볼지는 자산의 종류만으로 정할 수 없어서, Recipe가 표현하는 정보 구조와 함께 결정해야 했다.

개별 prop이 더 단순하지 않을까

descriptor 없이 값을 최상위 prop으로 받는 방식도 검토했다.

tsx
<ListRowRegular leadingIcon="user" label="계정" />

Leading이 아이콘 하나로 고정돼 있다면 이 방식이 가장 단순하고 강하다. IconName 밖의 값은 들어올 수 없고 중간 객체가 없어 읽기도 짧다. 문제는 Leading에 아이콘과 이미지가 모두 필요한 경우였다.

tsx
<ListRowRegular leadingIcon="user"
  leadingImageSrc="/avatar.png" leadingImageAlt="프로필" />

아무 제약이 없다면 아이콘과 이미지를 동시에 줄 수 있다.

개별 prop도 discriminated union으로 만들면 이 문제를 막을 수 있다.

typescript
type FlatLeading =
  | { leadingType: 'icon'; leadingIcon: IconName }
  | { leadingType: 'image';
      leadingImageSrc: string; leadingImageAlt: string }
  | { leadingType: 'none' };

모든 축을 최상위 prop으로 펼친 이 형태를 아래에서는 Flat union이라고 부른다. 3편이 기각한 Flat API가 discriminated union으로 제약을 표현하면 이 모습이 된다.

직접 호출하는 경우만 놓고 보면 Flat union과 중첩 descriptor의 차단력은 비슷하다. 차이는 API가 커지고 다른 컴포넌트가 Recipe를 감싸기 시작할 때 나타났다.

오류가 가리키는 자리가 달랐다

같은 실수를 Flat union과 descriptor에서 각각 만들어보니 오류 메시지의 위치가 달랐다. 중첩 객체에서는 잘못된 속성을 직접 가리킨다.

text
error TS2353: Object literal may only specify known properties,
and 'label' does not exist in type
'{ type: "badge"; tone: "accent" | "neutral"; }'.

Flat union에서는 컴포넌트의 전체 props가 union과 맞지 않는다는 형태로 오류가 커졌다.

text
error TS2322: Type '{ label: string; leadingType: "none";
trailingType: "badge"; trailingTone: "accent";
trailingLabel: string; }' is not assignable to
type 'IntrinsicAttributes & ...'.
Types of property 'trailingLabel' are incompatible.
Type 'string' is not assignable to type 'undefined'.

첫 번째는 label이 badge branch에 없다고 말하고, 두 번째는 전체 props를 늘어놓은 뒤 stringundefined에 할당할 수 없다고 말한다. 제한된 API는 잘못된 조합을 막지만 그 대가로 타입 오류가 복잡해진다. 이 비용까지 보면 문제 속성을 가까운 자리에서 가리키는 descriptor가 유리했다.

wrapper의 Omit을 지나면 Flat union의 관계가 풀렸다

제품팀이 디자인 시스템의 Recipe를 감싸고 일부 prop을 고정하는 경우도 있었다. 이때 흔히 Omit<ListRowRegularProps, 'label'>을 쓴다.

그런데 기본 Omit은 union의 각 branch에 분배되지 않는다. branch마다 키가 다르면 keyof에는 공통 키만 남는다.

typescript
type FlatProps =
  | { label: string; leadingType: 'icon';
      leadingSrc: string; leadingAlt: string }
  | { label: string; leadingType: 'none' };

// keyof FlatProps에는 공통 키인
// 'label'과 'leadingType'만 남는다.

이 타입에 Omit을 적용한 wrapper에서는 정상적인 icon prop까지 사라진다.

tsx
declare function Wrapper(
  props: Omit<FlatProps, 'label'>,
): ReactElement;

<Wrapper leadingType="icon" leadingSrc="s" leadingAlt="" />;
// 오류: leadingSrc가 타입에 존재하지 않는다.

모든 branch에 ?: never를 채워 키를 맞추면 이 오류는 사라진다. 대신 Omit을 거치며 branch 사이의 관계가 함께 약해져서, leadingType="none"인데 leadingSrc를 주는 조합이 통과할 수 있다. 막으려던 것과 같은 종류의 구멍이 wrapper 한 겹 뒤에서 다시 열린다.

피하려면 wrapper를 만드는 쪽이 분배형 utility를 써야 한다. T extends unknown ? Omit<T, K> : never처럼 union을 branch별로 펼친 뒤 적용하는 형태다. 하지만 디자인 시스템이 모든 wrapper 구현에 그것을 쓰도록 강제할 수는 없다.

중첩 descriptor에서는 union이 leading 속성 안에 남는다. 최상위 prop에 Omit을 적용해도 Leading 내부의 branch 관계는 그대로다.

tsx
type NestedProps = { label: string; leading: LeadingSpec };

declare function Wrapper(
  props: Omit<NestedProps, 'label'>,
): ReactElement;

<Wrapper leading={{ type: 'icon', src: 's' }} />;
// 오류: icon branch에는 src가 없다
Flat union
선언
union이 최상위에 있다
label · leadingType: 'icon'
leadingSrc · leadingAlt
label · leadingType: 'none'
Omit<FlatProps, 'label'>
결과
keyof에 공통 키만 남는다
labelleadingTypeleadingSrcleadingAlt
?: never로 키를 맞추면 오류는 사라진다. 대신 브랜치 사이 관계가 함께 풀린다
× 이 조합이 통과한다
leadingType="none" leadingSrc="s"
중첩 descriptor
선언
label
leading:
union이 속성 안에 있다
type: 'icon'
src · alt
type: 'none'
Omit<NestedProps, 'label'>
결과
label
leading:
그대로 남는다
type: 'icon'
src · alt
type: 'none'
잘못된 조합은 여전히 오류다
leading={{ type: 'icon', src: 's' }}
같은 Omit이 두 형태를 다르게 지나간다. 갈리는 것은 union 상자가 최상위에 놓였는지, leading 속성 안에 들어 있는지다

Pick, Partial, Required처럼 객체 타입을 변환하는 utility에도 같은 관점이 필요했다. 선언한 자리에서 제한돼 있는지만 볼 것이 아니라, 제품 코드가 감싸고 변환한 뒤에도 제약이 남는지를 봐야 했다.

모든 축을 descriptor로 만들지는 않았다

descriptor가 유리하다고 해서 모든 prop을 객체로 묶지는 않았다. 선택 기준은 축 하나를 고를 때 따라오는 값이 몇이나 되는가였다.

조건 개별 prop 중첩 descriptor
축의 형태 고르는 값 하나로 끝난다 고른 분기마다 따라오는 값이 다르다
disabled, divider, surface leading, trailing
오류 위치 컴포넌트 전체를 가리키기 쉽다 객체 내부 속성을 가리키기 쉽다
wrapper 타입 변환 union 관계가 깨질 수 있다 내부 union을 유지하기 쉽다
다른 플랫폼 모델 직접 대응하기 어렵다 enum이나 sealed class로 대응하기 쉽다

축을 고르는 값 하나만 필요하면 개별 prop이 더 단순했다. divider="inset"을 고른다고 함께 따라와야 하는 값은 없다. 반면 고른 분기에 따라 함께 요구되거나 금지되는 값의 묶음이 달라지면 descriptor가 그 관계를 가까이 붙들어뒀다.

법칙으로 쓰지는 않았다. 분기마다 값이 달라지는 축이라도 최상위 판별자로 남기는 편이 나은 자리가 있다. 축마다 제약 관계를 가장 덜 잃는 형태를 고르는 것이 목표였지, API 전체를 한 형식으로 통일하는 것이 목표는 아니었다.

descriptor도 완전한 방어막은 아니었다

객체를 값이 열거된 union으로 만들었다고 모든 호출 경로에 같은 수준의 검사가 걸리지는 않는다.

판별자가 넓어질 수 있다

인라인 객체에는 문맥 타입이 적용된다. 값을 변수로 분리하면 typestring으로 넓어진다.

tsx
const leading = { type: 'icon', name: 'user' };

<ListRowRegular leading={leading} />;
// LeadingSpec에 할당되지 않는다.

오류 메시지가 실제 원인과 다른 branch를 가리키면 제품 개발자는 문제를 찾기 어렵다. 선언 지점에 satisfies LeadingSpec을 붙이면 literal을 유지하면서 계약을 그 자리에서 확인할 수 있다.

초과 프로퍼티 검사는 객체 리터럴에 강하게 적용된다

인라인 객체에서는 공개 API에 없는 size를 잡는다. 객체를 변수로 분리한 뒤 전달하면 그 검사가 사라진다.

tsx
const leading = { type: 'icon', name: 'user', size: 40 } as const;

<ListRowRegular leading={leading} />;
// size가 있어도 할당이 통과한다.

Recipe가 size를 읽지 않으니 렌더 결과가 달라지지는 않는다. 그래도 공개 API에 없는 속성은 항상 타입 오류가 된다고 말할 수는 없다. 여기서도 satisfies가 선언 지점에서 다시 잡아준다.

금지한 속성과 선언하지 않은 속성은 방어력이 다르다

선언에 아예 없는 속성은 초과 프로퍼티 검사에만 기대기 때문에 값이 객체 리터럴을 벗어나면 통과한다. 위의 size가 그런 경우다. 반면 ?: never로 명시적으로 금지한 속성은 타입 자체가 호환되지 않아서 변수로 분리하든 map을 거치든 걸린다.

typescript
const rows = data.map((item) => ({
  id: item.id,
  variant: 'default' as const,
  label: item.name,
  count: item.count,
}));
// error TS2322: Types of property 'count' are incompatible.
// Type 'number' is not assignable to type 'undefined'.

다만 ?: never도 한 자리는 비워둔다. optional 프로퍼티에 붙은 nevernever | undefined로 읽혀서 undefined를 명시적으로 넘기는 것은 막지 않는다.

tsx
<ListRowBasic variant="default" label="신청 내역" count={undefined} />;
// 통과한다.

exactOptionalPropertyTypes를 켜면 생략과 undefined가 구별되면서 이 호출도 막힌다. 이 옵션은 strict에 포함되지 않아 따로 켜야 한다. 렌더 결과는 어느 쪽이든 같으니 급한 구멍은 아니지만, 계약의 경계가 타입 선언만이 아니라 컴파일러 옵션에도 걸려 있다는 것은 알고 있어야 했다.

세 방어를 호출 경로 위에 나란히 놓으면 구멍이 어디에 몰려 있는지가 보인다.

호출 경로판별자초과 속성?: never호출부에 직접 쓴 객체잡힘잡힘잡힘변수로 분리놓침놓침잡힘변수로 분리+ as const잡힘놓침잡힘변수로 분리+ satisfies LeadingSpec잡힘잡힘잡힘undefined를 명시+ count={undefined}놓침
마지막 행은 exactOptionalPropertyTypes를 켜면 잡힘으로 바뀐다. 이 옵션은 strict에 들어 있지 않다.
놓치는 칸은 값이 객체 리터럴을 벗어나는 줄에 몰려 있다. satisfies 한 줄이 그 줄을 되돌린다

descriptor가 놓치는 것은 공개 API가 모르는 속성이 조용히 실려 오는 경우였다. 승인되지 않은 조합을 금지한 계약은 그보다 넓은 경로에서 버텼다. 강한 계약이었지만, TypeScript로 타입을 짜면 어디서나 만나는 한계였다.

객체 prop은 리렌더를 늘릴까

descriptor를 쓰면 자주 나오는 반론이 있다. 렌더할 때마다 새 객체를 만드니 prop identity가 달라지고, 그 결과 자식이 계속 리렌더되지 않느냐는 질문이다.

새 객체가 만들어지는 것은 맞다. 다만 JSX slot도 같은 특성을 가진다. React element 역시 type, props, key 등을 가진 객체이고, JSX 표현식도 렌더할 때 새 element 객체를 만든다.

확인해보려고 같은 자식을 memo로 감싸고 descriptor와 JSX element를 각각 인라인으로 넘겨봤다. memo의 기본 얕은 비교는 둘의 identity를 똑같이 다르게 봤다. 객체 identity는 descriptor만의 문제가 아니라 객체, 배열, 함수, JSX element를 인라인으로 전달하는 API가 공유하는 특성에 가깝다.

descriptor를 선택한 이유는 자동으로 성능이 좋아지기 때문이 아니었다. 공개 API에서 표현할 수 있는 값의 범위를 좁히고, 구조적으로 비교 가능한 데이터를 유지하기 위해서였다.

타입을 더 강하게 만든 것이 아니라 질문을 바꿨다

그래서 공개 API에서는 React element 대신 값이 정해진 데이터를 받았다. 아이콘은 IconName으로 열거했고, 콘텐츠 이미지는 URL을 받되 표현 규칙은 Recipe가 그대로 쥐었다.

축을 묶는 방식도 하나로 통일하지 않았다. 분기마다 따라오는 값이 다른 축은 discriminated union으로 묶고, 값 하나로 끝나는 축은 개별 prop으로 남겼다. 객체를 변수로 분리할 때는 satisfies로 선언 지점의 계약을 확인했고, wrapper와 utility type을 거친 뒤에도 union 관계가 남는지 봤다.

바꾼 것은 타입의 강도가 아니라 물어보는 대상이다.

이 자리에 온 React element가 무엇인가는 런타임에는 확인할 수 있어도 공개 타입 계약으로 답하기 어려운 질문이었다. 정적 타입에 답을 남기려는 시도는 memo와 조건부 렌더부터 막았다. 반면 이 데이터가 어떤 Recipe의 어떤 분기를 골랐는가는 discriminated union이 원래 잘하는 질문이었다. 같은 제약을 타입 시스템이 이미 표현할 수 있는 모델 위로 옮긴 것이 descriptor다.

그렇다고 모든 구멍이 막히지는 않는다. any, 강제 assertion, 외부 데이터, 객체 리터럴 밖의 초과 속성은 여전히 남는다. 타입으로 모든 잘못을 잡으려 하면 API가 먼저 이상해지고 그러고도 다 잡히지 않는다. 타입이 잘하는 일을 시키고 나머지는 다른 층에 맡기는 편이 나았다.

공개 Recipe가 받은 것은 승인된 UI 안에 들어갈 데이터였다. UI를 그리는 방법은 끝까지 안쪽에 남았다.

Leading은 그렇게 제한됐다. 반대쪽 Trailing에는 Arrow와 Button이 온다. 그 자리는 무엇을 받을지 정하는 것만으로 끝나지 않았다. 무엇이 보이느냐가 무엇을 누를 수 있느냐까지 정하기 때문이다. 다음 글은 그 이야기다.