3. 타입과 계약
React 핸들러 타입과 래퍼가 제공하는 프롭 계약을 선언에 명시합니다. 라이브러리 래퍼는 허용할 프롭 범위를 좁히고 구조에 맞는 방법으로 전달합니다. 일반 TypeScript 타입 규칙은 동반 스킬이 다루고 여기서는 React 문맥만 봅니다.
3.1 핸들러와 래퍼 프롭스 타입은 기존 계약에서 가져옵니다
같은 시그니처를 직접 다시 적어 생기는 계약 불일치를 막습니다.
React 핸들러와 래퍼 프롭스의 타입은 기존 계약에서 가져옵니다. 타입을 붙이는 기본 위치는 TypeScript 규칙 1.3를 따릅니다.
| 자리 | 타입 출처 |
|---|---|
| 커링 팩토리가 반환하는 핸들러 | MouseEventHandler<...> 같은 React 별칭을 팩토리 반환 타입에 적습니다 |
Ui* 래퍼 사용처 | 내부 라이브러리의 원본 타입 대신 래퍼가 내보낸 Ui*Props를 가져옵니다 |
팩토리가 반환하는 함수에는 React의 문맥 타입이 붙지 않으므로 반환 타입을 생략하면 안쪽 매개변수가 암시적 any가 됩니다.
query.select 같은 훅 옵션의 일회성 문맥 콜백은 React 핸들러 구현이 아니므로 대상이 아닙니다.
팩토리 반환 타입을 적지 않아 이벤트가 암시적 any가 됩니다.
const handleRowSelectToggle = (rowId: string) => (event) => {
event.preventDefault();
toggleSelection(rowId);
};
팩토리 반환 타입을 기존 별칭으로 고정합니다.
import type {MouseEventHandler} from "react";
/**
* 행 id를 커링으로 고정. 목록 JSX에 인라인 래퍼를 두지 않음
*/
const handleRowSelectToggle =
(rowId: string): MouseEventHandler<HTMLButtonElement> =>
(event) => {
event.preventDefault();
toggleSelection(rowId);
};
래퍼를 쓰면서 라이브러리 원본 프롭스를 참조합니다.
import type {ButtonProps} from "@mui/material";
const handleSubmitClick: ButtonProps["onClick"] = (event) => {
event.preventDefault();
};
래퍼가 노출한 계약을 참조합니다.
import type {UiButtonProps} from "@/component/ui/button/ui-button";
/**
* 저장 버튼 클릭 기본 동작 차단
*/
const handleSubmitClick: UiButtonProps["onClick"] = (event) => {
event.preventDefault();
};
함께 보기
3.2 라이브러리 래퍼는 필요한 프롭만 공개합니다
화면의 라이브러리 의존성을 제한하고 교체 시 수정 범위를 줄입니다.
라이브러리 컴포넌트는 화면에서 직접 쓰지 않고 Ui* 래퍼를 거칩니다.
업그레이드, 교체 시 수정 범위를 래퍼에 모으고, 화면에 필요한 계약만 엽니다.
프롭 종류별 선언
flowchart LR
q1{"안쪽 컴포넌트가<br>받는 프롭인가?"} -- 예 --> q2{"라이브러리 스타일<br>주입 프롭인가?"} -- 아니요 --> r3("인덱스 접근으로 하나씩 열기")
q1 -- 아니요 --> r1("자기 프롭. 타입을 직접 적기")
q2 -- 예 --> r2("선언하지 않기")| 프롭 종류 | 선언 방법 |
|---|---|
라이브러리에 이미 있는 표시 프롭 (color, padding, size) | ButtonProps["color"]처럼 인덱스 접근으로 하나씩 엽니다 |
안쪽 컴포넌트가 받지 않는 자기 프롭 (icon, label, helperText) | 타입을 직접 적습니다 |
| 라이브러리 스타일 주입 프롭 (테마 스타일, 클래스 맵, 렌더 태그 교체) | 선언하지 않습니다 |
| DOM 속성 | 규칙 3.3를 따릅니다 |
export type UiButtonProps = ButtonProps처럼 원본 프롭스 전체를 공개하지 않습니다.
스타일 주입 지점까지 열면 CSS 규칙 3.4의 경계를 지킬 수 없습니다.
자기 프롭 구분
UiIconButtonProps의 icon은 자기 프롭이지만, 안쪽 컴포넌트도 받는 UiTableRowProps의 selected는 아닙니다.
인덱스 접근은 이미 있는 프롭을 그대로 열 때만 쓰며, 상속된 프롭도 바깥 타입 이름으로 접근합니다.
| 함께 판단할 내용 | 기준 |
|---|---|
| 값을 직접 적어 계약을 좁힘 | 의도적으로 좁힐 때만 허용하며 TypeScript 규칙 6.5에 따라 이유를 남깁니다 |
ref 공개 | 규칙 5.4 |
| 프롭 전달 방식 | 규칙 3.4 |
라이브러리 타입을 그대로 내보냅니다.
export type UiButtonProps = ButtonProps;
export const UiButton = (props: UiButtonProps) => {
return <Button {...props} />;
};
표시 프롭은 인덱스 접근으로 열고 자기 프롭은 직접 적습니다.
import {Button} from "@mui/material";
import type {ButtonProps} from "@mui/material";
import type {MouseEventHandler, ReactNode} from "react";
/**
* 버튼 래퍼 계약
*
* sx, classes, component 같은 스타일 주입 지점은 열지 않음
*/
export interface UiButtonProps {
children: ReactNode;
/**
* 강조 수준
*/
variant?: ButtonProps["variant"];
/**
* 크기
*/
size?: ButtonProps["size"];
/**
* 요청이 끝날 때까지 눌리지 않음
*/
isLoading?: boolean;
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export const UiButton = (props: UiButtonProps) => {
return (
<Button variant={props.variant} size={props.size} disabled={props.isLoading} onClick={props.onClick}>
{props.children}
</Button>
);
};
함께 보기
- 3.3 래퍼에 DOM 속성을 열 때는 세 단계를 차례로 시도합니다
- 3.1 핸들러와 래퍼 프롭스 타입은 기존 계약에서 가져옵니다
- 3.4 래퍼 형태를 고르고 조건이 맞을 때만
{...props}로 넘깁니다 - TypeScript 6.5 컨벤션 예외에는 확인할 수 있는 이유를 적습니다
3.3 래퍼에 DOM 속성을 열 때는 세 단계를 차례로 시도합니다
프롭 타입 충돌을 해결하면서 필요한 DOM 속성과 이벤트를 유지합니다.
공개할 계약은 규칙 3.2로 정하고, DOM 속성은 아래 순서로 엽니다.
같은 요소로 {...props}를 전달하는 래퍼는 1, 2단계 중 컴파일되는 형태를 씁니다.
flowchart LR
q1{"DOM 계약과<br>호환되는가?"} -- 아니요 --> q2{"부딪히는 이름만<br>빼면 되는가?"} -- 아니요 --> r3("3단계. 전달할<br>DOM 프롭만 선언")
q1 -- 예 --> r1("1단계. extends로<br>그대로 상속")
q2 -- 예 --> r2("2단계. 그 이름만 Omit으로 빼고<br>인덱스 접근으로 다시 열기")extends <요소>HTMLAttributes<T>extends Omit<<요소>HTMLAttributes<T>, "size">로 충돌하는 이름만 빼고 인덱스 접근으로 다시 엽니다extends없이 전달할 DOM 프롭만 선언합니다
| 함께 판단할 내용 | 기준 |
|---|---|
| 요소 전용 타입 | 버튼은 ButtonHTMLAttributes, 입력은 InputHTMLAttributes, 셀은 TdHTMLAttributes를 씁니다 |
| 전용 타입이 없는 요소 | tr처럼 전용 타입이 없을 때만 HTMLAttributes를 씁니다 |
| 호환되는 좁히기 | string을 문자열 리터럴 유니언으로 좁히면 1단계가 컴파일됩니다. 숫자 size를 문자열 크기 이름으로 바꾸는 경우에는 2단계가 필요합니다 |
| 요소 타입이 다름 | 겉은 div, 이벤트 대상은 input이면 Omit만으로 해결하지 않습니다 |
요소 타입이 다르면 필요한 DOM 프롭을 string, ChangeEventHandler<HTMLInputElement> 같은 플랫폼 타입으로 적습니다.
HTMLAttributes만 쓰면 disabled, type, colSpan 같은 전용 속성을 잃습니다.
value, onChange처럼 DOM이 정한 이름은 라이브러리 고유 계약이 아닙니다.
HTMLAttributes를 상속하면 style도 열리므로
사용 여부는 CSS 규칙 3.6를 따릅니다.
프롭 타입 하나의 충돌 때문에 DOM 속성 전체를 제외합니다.
// id, role, tabIndex, aria-*, 이벤트를 전부 잃고 다섯 개만 남음
export interface UiButtonProps {
className?: string;
children?: ReactNode;
color?: ButtonProps["color"];
disabled?: ButtonProps["disabled"];
onClick?: MouseEventHandler<HTMLButtonElement>;
}
1단계. 같은 이름의 프롭도 호환되면 그대로 상속합니다.
import {Button} from "@mui/material";
import type {ButtonProps} from "@mui/material";
import {clsx} from "clsx";
import type {ButtonHTMLAttributes} from "react";
/**
* 라이브러리 버튼의 강조 단계를 여는 계약
*
* color의 문자열 리터럴은 DOM의 string에 할당 가능해 Omit 불필요
*/
export interface UiButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* 강조 단계
*/
color?: ButtonProps["color"];
}
export const UiButton = (props: UiButtonProps) => {
return <Button {...props} className={clsx("ui_button__root", props.className)} />;
};
2단계. 호환되지 않는 정렬 프롭만 빼고 다시 엽니다.
import {TableCell} from "@mui/material";
import type {TableCellProps} from "@mui/material";
import {clsx} from "clsx";
import type {TdHTMLAttributes} from "react";
/**
* 라이브러리 셀의 정렬과 여백을 여는 계약
*
* align의 inherit은 DOM td 타입에 없어 그 이름만 빼고 다시 선언
*/
export interface UiTableCellProps extends Omit<TdHTMLAttributes<HTMLTableCellElement>, "align"> {
/**
* 내용 가로 정렬
*/
align?: TableCellProps["align"];
/**
* 셀 여백
*/
padding?: TableCellProps["padding"];
}
export const UiTableCell = (props: UiTableCellProps) => {
return <TableCell {...props} className={clsx("ui_tableCell__root", props.className)} />;
};
3단계. 요소 타입이 달라 필요한 프롭만 선언합니다.
import {TextField} from "@mui/material";
import type {TextFieldProps} from "@mui/material";
import {clsx} from "clsx";
import type {ChangeEventHandler} from "react";
/**
* 라벨과 값을 받는 한 줄 입력 계약
*
* 겉은 `div`, 이벤트는 안쪽 `input`이 받아 요소 전용 타입을 그대로 못 씀
* 입력 이름을 반드시 보이려고 label은 선택적인 ReactNode 대신 필수 문자열
*/
export interface UiTextFieldProps {
/**
* 입력 위에 보이는 이름
*/
label: string;
/**
* 최상위에 얹을 클래스
*/
className?: string;
/**
* 입력 식별자
*/
id?: string;
/**
* 입력값
*/
value: string;
/**
* 입력이 바뀔 때
*/
onChange: ChangeEventHandler<HTMLInputElement>;
/**
* 오류 표시 여부
*/
error?: TextFieldProps["error"];
}
export const UiTextField = (props: UiTextFieldProps) => {
return (
<TextField
className={clsx("ui_textField__root", props.className)}
id={props.id}
label={props.label}
value={props.value}
onChange={props.onChange}
error={props.error}
/>
);
};
함께 보기
- 3.2 라이브러리 래퍼는 필요한 프롭만 공개합니다
- CSS 3.6 인라인
style로 스타일을 주지 않습니다 - TypeScript 1.1 새 타입을 선언하기 전에 이미 있는 타입과 스키마를 먼저 씁니다
3.4 래퍼 형태를 고르고 조건이 맞을 때만 {...props}로 넘깁니다
각 프롭이 전달되는 요소를 코드에서 확인할 수 있습니다.
전달 방식 고르기
기본은 프롭을 이름으로 하나씩 전달하는 것입니다.
{...props}는 아래 세 조건을 모두 만족할 때만 씁니다.
flowchart LR
q1{"안쪽 요소가<br>하나인가?"} -- 예 --> q2{"선언한 프롭을<br>안쪽이 전부 받는가?"} -- 예 --> q3{"DOM 속성을<br>extends로 열 수 있는가?"} -- 예 --> r3("{...props}로 전달")
q1 -- 아니요 --> r1("이름으로<br>하나씩 전달")
q2 -- 아니요 --> r2("전달할 DOM 프롭만<br>선언해 이름으로 전달")
q3 -- 아니요 --> r4("이름으로<br>하나씩 전달")| 조건 | 확인 방법 |
|---|---|
| 안쪽 요소가 하나임 | 반환하는 JSX에 요소가 하나입니다 |
| 자기 프롭이 없음 | 선언한 프롭을 안쪽 컴포넌트가 전부 받습니다. 구분은 규칙 3.2를 따릅니다 |
DOM 속성을 extends로 열 수 있음 | 규칙 3.3의 1, 2단계입니다 |
자기 프롭이 있으면 3단계처럼 전달할 DOM 프롭만 선언하고, 전부 이름으로 넘깁니다. 스프레드는 초과 프롭을 검사하지 않으므로 자기 프롭이 DOM에 새지 않는지 리뷰에서 확인합니다.
계약이 커질 때
| 계약이 커지는 상황 | 처리 |
|---|---|
| 라이브러리 API를 따라 프롭이 서른 개로 늘어날 것 같음 | 우리 어휘로 계약을 다시 쓰고 라이브러리 어휘는 구현 안에 둡니다 |
| 그래도 프롭 수가 줄지 않음 | 규칙 4.1에 따라 쓰임새별 변형으로 나눕니다 |
| 안쪽 부품을 외부에서 조립해야 함 | headerProps, buttonProps 같은 내부 프롭 묶음을 만들지 않고 규칙 4.4에 따라 children으로 엽니다 |
자기 프롭까지 스프레드로 전달합니다.
export interface UiIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode;
}
// 안쪽 컴포넌트가 거르지 않으면 icon이 DOM까지 내려감. 컴파일은 통과
export const UiIconButton = (props: UiIconButtonProps) => {
return (
<Button {...props}>
{props.icon}
{props.children}
</Button>
);
};
자기 프롭이 있으므로 프롭을 이름으로 전달합니다.
/**
* 아이콘만 있는 버튼
*
* `icon`과 `label`은 안쪽 컴포넌트가 모르는 자기 프롭이라 타입을 직접 선언
* `disabled`는 라이브러리에 이미 있어 인덱스 접근으로 가져옴
*/
export interface UiIconButtonProps {
/**
* 최상위에 얹을 클래스
*/
className?: string;
/**
* 버튼 안에 그릴 아이콘
*/
icon: ReactNode;
/**
* 스크린 리더가 읽을 이름. `aria-label`로 전달
*/
label: string;
/**
* 비활성 여부
*/
disabled?: ButtonProps["disabled"];
/**
* 눌렀을 때
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export const UiIconButton = (props: UiIconButtonProps) => {
return (
<Button
className={clsx("ui_iconButton__root", props.className)}
aria-label={props.label}
disabled={props.disabled}
onClick={props.onClick}
>
{props.icon}
</Button>
);
};
서로 다른 요소에 프롭을 이름으로 전달합니다.
/**
* 라벨과 보조 설명을 붙인 입력 한 줄
*/
export interface UiFieldProps {
/**
* 최상위에 얹을 클래스
*/
className?: string;
/**
* 입력 위에 붙는 라벨
*/
label: string;
/**
* 입력 아래 보조 설명
*/
helperText?: string;
/**
* 라벨과 입력을 잇는 id
*/
inputId: string;
/**
* 입력값
*/
value: string;
/**
* 입력이 바뀔 때
*/
onChange: ChangeEventHandler<HTMLInputElement>;
}
export const UiField = (props: UiFieldProps) => {
return (
<div className={clsx("ui_field__root", props.className)}>
<label className={clsx("ui_field__label")} htmlFor={props.inputId}>
{props.label}
</label>
<input id={props.inputId} value={props.value} onChange={props.onChange} />
{props.helperText && <span className={clsx("ui_field__helper")}>{props.helperText}</span>}
</div>
);
};
세 조건을 모두 만족하여 스프레드로 전달합니다.
/**
* 표 줄
*
* 감싸는 컴포넌트의 프롭이 DOM 계약과 호환돼 `HTMLAttributes`를 그대로 받음
*/
export interface UiTableRowProps extends HTMLAttributes<HTMLTableRowElement> {
/**
* 선택된 줄로 표시할지
*/
selected?: TableRowProps["selected"];
}
export const UiTableRow = (props: UiTableRowProps) => {
return (
<TableRow {...props} className={clsx("ui_tableRow__root", props.className)} />
);
};
함께 보기