글 목록으로개발 경험

TypeScript로 호출부의 실수를 줄이는 방법

타입에 맞는 입력을 호출부에서 구분하는 모습을 그린 일러스트

글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.

필드 이름을 enum으로 모아도 값의 타입까지 맞아지는 건 아니었어요. price에 문자열을 넣거나 함께 쓰면 안 되는 props를 넘기는 실수는 따로 남았어요. 이런 조합을 호출하는 자리에서 드러내려고 satisfies와 제네릭, 유니언을 쓰고 있어요. optional property도 읽는 값뿐 아니라 객체를 만들 때 어떤 키를 생략할 수 있는지 함께 봐요.

satisfies로 검사하면서 추론을 유지하기

경로 목록에서는 필요한 이름이 빠지지 않았는지 검사하면서도 각 경로를 구체적인 문자열로 쓰고 싶었어요. 아래에서는 as const로 리터럴 타입을 유지하고, satisfies로 필수 키와 경로 형식을 검사해요.

typescript
type RouteName = 'home' | 'profile' | 'settings';
 
const ROUTES = {
  home: '/',
  profile: '/profile',
  settings: '/settings',
} as const satisfies Record<RouteName, `/${string}`>;
 
ROUTES.profile;
// '/profile'로 추론된다.

변수 타입을 Record<RouteName, string>으로 지정하면 ROUTES.profile도 넓은 string으로 읽힐 수 있어요. 두 문법을 함께 쓰면 '/profile'이라는 값을 유지한 채 빠진 키나 잘못된 경로를 찾을 수 있어요. 결제 수단별 문구처럼 빠짐없이 채워야 하는 객체에도 같은 방식으로 적용해요.

typescript
const LABELS = {
  normal: '일반 결제',
  coupon: '쿠폰 결제',
  // cancel 누락 시 컴파일 오류
} as const satisfies Record<'normal' | 'coupon' | 'cancel', string>;

값이 undefined인 것과 키가 없는 것을 구분해요

typescript
interface Example {
  requiredKey: number | undefined;
  optionalKey?: number;
}
 
const a: Example = {
  requiredKey: undefined,
};

두 프로퍼티 모두 읽으면 number | undefined가 될 수 있어요. 다만 객체를 만들 때의 조건은 달라요.

  • requiredKey는 키가 있어야 하고, 값으로 undefined를 허용해요.
  • optionalKey는 키 자체를 생략할 수 있어요.

이 차이는 exactOptionalPropertyTypes를 켰을 때 더 분명해져요. optionalKey?: number는 키를 빼도 된다는 뜻이고, 값을 undefined로 적어도 된다는 뜻은 아니에요. 직접 대입하려면 값 타입에도 undefined를 넣어야 해요.

요청 본문으로 보낼 때는 한 번 더 구분해요. JSON.stringify는 값이 undefined인 프로퍼티를 생략해요. 자바스크립트 객체에 키를 남겼더라도 JSON에 그대로 실리는 것은 아니에요.

제네릭으로 value와 callback의 타입을 연결하기

tsx
interface SelectProps<T extends string> {
  value: T;
  onValueChange: (value: T) => void;
}
 
function Select<T extends string>({ value, onValueChange }: SelectProps<T>) {
  return <button onClick={() => onValueChange(value)}>{value}</button>;
}

이 Select는 value로 받은 값을 콜백에도 그대로 넘겨요. 호출부에서 PaymentMethod를 지정하면 두 prop이 같은 유니언을 사용하고, 콜백 안에서도 결제 수단의 범위를 알 수 있어요.

tsx
type PaymentMethod = 'normal' | 'coupon';
 
<Select<PaymentMethod>
  value="coupon"
  onValueChange={(method) => {
    // method: 'normal' | 'coupon'
  }}
/>;

두 prop을 각각 string으로 열어 두는 대신, 같은 타입을 써야 한다는 관계를 제네릭으로 표현했어요.

서로 배타적인 props를 union으로 표현하기

값과 변경 콜백을 받는 방식, label만 보여 주는 방식을 한 컴포넌트에서 지원할 때도 있어요. 아무 속성이나 선택적으로 받으면 label과 value가 동시에 들어올 수 있어요. 각 방식에서 받지 않을 속성을 never로 적어 허용할 조합을 나눴어요.

tsx
type ControlledProps<T extends string> = {
  value: T;
  onValueChange: (value: T) => void;
  label?: never;
};
 
type LabelOnlyProps = {
  label: string;
  value?: never;
  onValueChange?: never;
};
 
type Props<T extends string> = ControlledProps<T> | LabelOnlyProps;
 
function Field<T extends string>(props: Props<T>) {
  if ('label' in props) return <span>{props.label}</span>;
  return <button onClick={() => props.onValueChange(props.value)}>{props.value}</button>;
}
tsx
<Field label="읽기 전용" />;                         // 가능
<Field value="active" onValueChange={() => {}} />; // 가능
<Field label="잘못된 조합" value="active" />;      // 컴파일 오류

함수 오버로드로 호출 방식을 나눌 수도 있어요. 이 컴포넌트에서는 props 유니언을 쓰면 구현부에서도 어떤 속성을 사용할 수 있는지 확인하기 편했어요.

이 예제에서는 호출부에 잘못된 조합을 넘기면 바로 타입 오류가 나요. 구현부도 props를 확인한 뒤 그 방식에 맞는 속성만 읽을 수 있어요.

참고