interface의 union과 never 타입을 활용하여 서로 의존적인 prop을 가진 interface 정의하기

@p-iknow 🎹 · June 19, 2023

서로 의존적인 필드를 가진 Interface 란?
배경
첫번째 이슈
첫번째 이슈 해결
두번째 이슈
두번째 이슈 해결
Summary

typescript

서로 의존적인 필드를 가진 Interface 란?

isDarkisLight 이라는 prop을 가진 인터페이스가 있다고 하자.

interface DependentProps {
  isDark: boolean
  isLigrht: boolean
}

isDarktrue 인 경우 isLightfalse 여야 한다. isDarkfalse 인 경우 isLighttrue 여야 한다. 하나의 property의 상태가 다른 property의 결과에 영향을 미친다. 이런 경우 서로 의존적인 필드가 있다고 말한다. 사내의 리엑트 컴포넌트의 Props 를 정의중에 이런 의존적인 field를 가진 상황을 마주했다. 해당 상황에 대해 더 살펴보자.

배경

사내에 있는 이미지를 보다 편히 쓰기위해 아래와 같은 CustomImg 컴포넌트를 사용한다. resourceId를 주입하면 dark, light mode에 따르는 cdn이미지를 추출해서 사용자의 color-scheme 에 맞는 이미지를 제공한다. 여기서 resouceId 는 사내에 보유한 모든 이미지의 주소의 union 으로 정의된다. 이렇게 이미지 주소를 정확하게 몰라도, 에디터의 자동 타입추론 기능을 통해 쉽게 img 주소를 작성할 수 있다.

이 타입은 언제 생성되는 것일까? cdn에 이미지를 업로드 하는 pipe line 에서 현재 우리가 보유한 이미지 주소를 GraphicResourceId 라는 타입으로 생성하고, <CustomImg/> 컴포넌트에서 사용한다.

export type GraphicResourceId =
  | 'https://cdn.xxx.com/graphic/icon/account-book-badge-won.png'
  | 'https://cdn.xxx.com/graphic/icon/account-book

type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet'> & {
  resourceId?: GraphicResourceId;
  forceLightColorScheme?: boolean
}
export const CutomImg = (props: Props) => {
    const {
      resourceId,
      forceLightColorScheme = false,
      ...htmlImgProps
    } = props;
    const { lightUrl, darkUrl } = getUrlOfMode(resourceId);

    return (
      <picture>
        {
          !forceLightColorScheme &&
         	<source media='(prefers-color-scheme: dark)' srcSet={darkUrl} />
      	}
        <img
          ref={ref}
          css={[dimension, customCss]}
          {...htmlImgProps}
          src={lightUrl}
        />
      </picture>
    );
  }),
);

첫번째 이슈

실제 서비스에서 <CutomImg/> 태그를 사용할 때는 resourceId를 직접 주입하는 일은 드물다. 대다수 이미지 url은 서버에서 내려주는 url을 그대로 CustomImg에 주입해서 사용한다. 다만 이때 편의를 위해 세팅한GraphicResourceId 타입이 문제가 된다.

image-20230618234100435

서버에서 가져온imgUrlstring 으로 추론되기 때문에 GraphicResourceId 타입에 할당 할 수 없고 위와 같이 에러가 발생하게 된다. 에러를 피하기 위해서는 아래와 같이 as 키워드를 이용한 타입캐스팅이 불가피하다.

const imgUrlFromSever = 'https://cdn.xxx.com/graphic/icon/account-book' as GraphicResourceId;
const Img = <CustomImg resourceId={imgUrlFromSever} />;

as keyword를 사용해야 하기 때문에 사용처에서의 편의성이 떨어진다.

첫번째 이슈 해결

interface ResourceIdProp {
  resourceIdFromServer?: string;
  resourceId?: BdsGraphicResourceId;
  forceLightColorScheme?: boolean;
}
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet'> &
  ResourceIdProp;

export const CutomImg = (props: Props) => {
    //...
);

이를 해결하기resoureIdFromServer prop을 추가했다. 이슈를 해결하기 위해서는 아래와 같은 요구사항이 있었고

resoureIdFromServer 를 쓸때는 resourceId 를 주입하지 않아도 되며, resourceId 를 쓸때는 resoureIdFromServer 를 주입하지 않는다.

요구사항을 만족시키기 위해 resoureIdFromServer, resourceId 를 둘다 optional 로 처리했다.

image-20230619001943583

이제 서버에서 가져온 imgUrl 을 사용할 때는 resourceIdFromServer 를 사용하면 별도 타입에러가 발생하지 않는다. 또한 불필요한 타입 캐스팅( as resourceId)을 하지 않아도 된다.

두번째 이슈

그러나 위 수정사항으로 인해 다른 이슈가 발생한다.

interface ResourceIdProp {
  resourceIdFromServer?: string;
  resourceId?: BdsGraphicResourceId;
  forceLightColorScheme?: boolean;
}

resoureIdFromServer , resourceId prop 둘다 optional 이기 때문에 아래와 같은 코드가 사용이 가능해진다.

// 꼭 필요한 prop이 전달되지 않을 수 있는 이슈
<CustomImg />; // Pass
// 두가지 prop을 다 주입할 수 있는 이슈
<CustomImg resourceId={...} resoureIdFromServer={...} /> // Pass

타입으로 props 의 사용 유형을 제약할 수 없다보니 컴포넌트의 사용시 실수로 prop 전달을 누락하는 휴먼에러가 발생할 수 있다. 또한 두 prop을 모두 적는 경우엔 컴포넌트 안쪽에서 두 prop을 어떻게 처리할지 모르기 때문에 컴포넌트의 동작을 예측할 수 없고 코드 안쪽을 확인해야 하는 부담이 생긴다. 따라서 아래와 같은 추가 요구사항이 필요하다.

resoureIdFromServer 를 쓸때는 resourceId 를 주입하지 않아도 되며, resourceId 를 쓸때는 resoureIdFromServer 를 주입하지 않는다. 단 이때 resourceId, resoureIdFromServer 는 동시에 optional type 일 수 없다.

위 요구사항을 달성하기 두 prop을 단순 optional 처리하는 것 이외에 다른 방법이 필요하다.

두번째 이슈 해결

interfaceunion 그리고 never type을 이용하면 요구사항을 충족할 수 있다. ResourceIdFromServerProp 인터페이스에서는 resourceIdFromServer 사용시에 resourceIdnever 임을 정의했고, 그 반대의 경우는 ResourceIdProp 인터페이스에 정의했다. 그후 ResourceIdFromServerProp | ResourceIdProp 두 인터페이스의 union을 Prop으로 만들었다.

interface ResourceIdFromServerProp {
  resourceIdFromServer: string;
  resourceId?: never;
}
interface ResourceIdProp {
  resourceIdFromServer?: never;
  resourceId: BdsGraphicResourceId;
}
type Props = (ResourceIdFromServerProp | ResourceIdProp)
	& Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet'>;

export const CutomImg = (props: Props) => {
    //...
);

그후 아래와 같이 테스트를 해보자.

image-20230619020035829

image-20230619015956437

// 잘못된 사용 케이스
const Prop_누락_케이스 = <CustomImg />; // Type Error
const Prop_동시에_사용하는_케이스 = <CustomImg resourceId='...' resourceIdFromServer='...' />
// ^ Type Error

// 정상 사용 케이스
const ResourceId만_쓰는경우 = <CustomImg resourceId='...' /> // Pass
const ResourceIdFromServer만_쓰는경우 = <CustomImg resourceIdFromServer='...' /> // Pass

인터페이스의 union을 통한 prop 정의로 모든 케이스의 요구사항을 만족시켰다. 위와 같이 interface의 union을 사용하는 전략을 Tagged Union Types (Discriminated Unions) 이라고 한다. Tagged Union Types 더 자세한 내용은 링크에 접속하여 확인할 수 있다.

Summary

리엑트의 Props 정의를 할 때 각각의 prop이 서로 의존적일 경우 interfaceunionnever 타입을 활용하면 원하는 타입 표현을 할 수 있다.

@p-iknow 🎹
많은 것을 이해하고 싶습니다. 더 이해하기 위해 노력합니다.