본문으로 건너뛰기

8장 JSX에서 TSX로

1. 리액트 컴포넌트의 타입

1. 클래스 컴포넌트 타입

interface Component<P = {}, S = {}, SS = any> extends ComponentLifecycle<P, S, SS> {}
class Component<P, S> { }
class PureComponent<P = {}, S = {}, SS = any> extends Component<P, S, SS> { }

생명주기 메서드를 상속받으며 Propsstate를 제네릭 타입으로 지정해 안정성을 보장한다.

2. 함수 컴포넌트 타입

// 함수 선언을 사용한 방식
function Welcome(props: WelcomeProps): JSX.Element {}
// 함수 표현식을 사용한 방식 - React.FC 사용
const Welcome: React.FC<WelcomeProps> = ({ name }) => {};
// 함수 표현식을 사용한 방식 - React.VFC 사용
const Welcome: React.VFC<WelcomeProps> = ({ name }) => {};
// 함수 표현식을 사용한 방식 - JSX.Element를 반환 타입으로 지정
const Welcome = ({ name }: WelcomeProps): JSX.Element => {};
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {
// props에 children을 추가
(props: PropsWithChildren<P>, context?: any): ReactElement <any, any > | null;
propTypes?: WeakValidationMap<P> | undefined;
contextTypes?: ValidationMap<any> | undefined;
defaultProps?: Partial<P> | undefined;
displayName?: string | undefined;
}
type VFC<P = {}> = VoidFunctionComponent<P>;
interface VoidFunctionComponent<P = {}> {
// children 없음
(props : P, context?: any): ReactElement<any, any> | null;
propTypes?: WeakValidationMap<P> | undefined;
contextTypes?: ValidationMap<any> | undefined;
defaultProps?: Partial<P> | undefined;
displayName?: string | undefined;
}

FC 또는 VFC의 형태를 가장 많이 볼 수 있으며 18버전 이후 VFC가 삭제되고 React.FC에서 Children이 사라졌다. React.FC 또는 속성값을 반환해야 사용할 수 있다.

3. Children props 타입 지정

type PropsWithChildren <P> = P & { children?: ReactNode | undefined };
// example 1 특정 문자열
type WelcomeProps = {
children : "천생연분" | "더 귀한 분" | "귀한 분" | "고마운 분";
};
// example 2 문자열
type WelcomeProps = {
children : string;
};
// example 3 JSX요소
type WelcomeProps = {
children : ReactElement;
};

Children에 대한 타입을 지정할 수 있고, 더 구체적으로 제한하는 것이 가능하다.

4. render 메서드와 함수 컴포넌트의 반환

1. ReactElement

interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
type: T; // 요소의 타입, 예를 들면 'div', 'button', 또는 컴포넌트 이름
props: P; // 해당 요소의 속성(props)
key: Key | null; // 리액트의 리스트 렌더링에서 사용되는 고유 키
}

리엑트에서 렌더링 가능한 항목을 나타내는 타입이다. JSX로 작성된 요소들이 React.createElement 를 통해 반환된 후 가상 DOM에서 ReactElement 형태로 저장된다.

2. JSX.Element

declare global {
namespace JSX {
interface Element extends React.ReactElement<any, any> {}
}
}

JSX 문법을 사용할 때 필요하며, ReactElement를 상속하고 있기 때문에 동일한 역할을 수행할 수 있다.

3. ReactNode

/**
* @deprecated
* 현재는 ReactText, ReactChild, ReactFragment 모두 삭제되었다.
*/
type ReactText = string | number; // 문자열이나 숫자
type ReactChild = ReactElement | ReactText; // ReactElement 또는 ReactText
type ReactFragment = {} | Iterable<ReactNode>; // 리액트 조각
/**
* 책엔 아래와 같이 정의되어 있지만, 실제 ReactNode 타입 정의 내용도 조금 변경되었다.
*/
type ReactNode =
| ReactChild
| ReactFragment
| ReactPortal
| boolean
| null
| undefined;

ReactElement 뿐만 아니라 다양한 타입을 포함할 수 있다. 더 넓은 범위의 타입을 다루기 위해 사용된다.

5. ReactElement, ReactNode, JSX.Element 활용하기

1. JSX.Element

interface Props {
icon: JSX.Element;
}
const Item = ({ icon }: Props) => {
// prop으로 받은 컴포넌트의 props에 접근할 수 있다
const iconSize = icon.props.size;
return (<li>{icon}</li>);
};
// icon prop에는 JSX.Element 타입을 가진 요소만 할당할 수 있다
const App = () => {
return <Item icon={<Icon size={14} />} />
};

icon prop을 JSX.Element 타입으로 선언함으로써 해당 prop에는 JSX 문법만 삽입할 수 있다. 또한 icon.props에 접근하여 prop으로 넘겨받은 컴포넌트의 상세한 데이터를 가져올 수 있다.

2. ReactElement

interface IconProps {
size: number;
}
interface Props {
// ReactElement의 props 타입으로 IconProps 타입 지정
icon: React.ReactElement<IconProps>;
}
const Item = ({ icon }: Props) => {
// icon prop으로 받은 컴포넌트의 props에 접근하면, props의 목록이 추론된다
const iconSize = icon.props.size;
return <li>{icon}</li>;
};

원하는 컴포넌트의 props를 ReactElement의 제네릭으로 지정해줄 수 있다.

3. ReactNode

interface MyComponentProps {
children?: React.ReactNode;
// ...
}

컴포넌트가 렌더링 할 수 있는 어떤 타입의 속성값이든 받을 수 있게 만들 수 있다.

type PropsWithChildren<P = unknown> = P & {
children?: ReactNode | undefined;
};
interface MyProps {
// ...
}
type MyComponentProps = PropsWithChildren<MyProps>;

이런 식으로 컴포넌트가 다양한 형태를 가질 수 있게 하고 싶을 때 사용된다.

2. 타입스크립트로 리액트 컴포넌트 만들기

공통 컴포넌트에 어떤 타입의 속성이 제공되어야 하는지 알려주며, 필수 속성이 없는 경우 에러를 발생시켜 유지보수 과정에서의 실수를 사전에 막을 수 있다.

1. JSX로 구현된 Select 컴포넌트

const Select = ({ onChange, options, selectedOption }) => {
const handleChange = (e) => {
const selected = Object.entries(options)
.find(([_, value]) => value === e.target.value)?.[0];
onChange ?.(selected);
};
return (
<select
onChange ={handleChange}
value ={selectedOption && options[selectedOption]}
>
{Object.entries(options).map(([key, value]) => (
<option key={key} value={value}>
{value}
</option>
))}
</select>
);
};

해당 코드에서 속성 값으로 어떤 타입을 전달해야 할지 명확히 알 수 없기 때문에 추가적인 설명이 필요하다.

2. JSDocs로 일부 타입 지정하기

/**
* Select 컴포넌트
* @param {Object} props - Select 컴포넌트로 넘겨주는 속성
* @param {Object} props.options - { [key: string]: string } 형식으로 이루어진 option 객

* @param {string | undefined } props .selectedOption - 현재 선택된 option의 key값
(optional)
* @param {function} props.onChange - select 값이 변경되었을 때 불리는 callBack 함수
(optional)
* @returns {JSX.Element}
*/

JSDocs를 활용하면 각 속성의 대략적인 타입과 어떤 역할을 하는지 파악할 수 있지만,options가 어떤 형식의 객체를 나타내는지나 onChange의 매개변수 및 반환 값에 대한 구체적인 정보를 알기 쉽지 않아서 잘못된 타입이 전달될 수 있는 위험이 존재한다

3. props 인터페이스 적용하기

type Option = Record <string, string>; // {[key : string]: string}
interface SelectProps {
options: Option;
selectedOption?: string ;
onChange?: (selected?: string) => void;
}
const Select = ({ options, selectedOption, onChange }: SelectProps): JSX.Element =>{
//...
};

컴포넌트에 타입을 정의할 수 있으며, 각 속성에 타입을 명확하게 정의하고 옵셔널 프로퍼티를 사용해 부모에게 onChange 속성을 받지 못해도 유연하게 사용할 수 있다.

4. 리액트 이벤트

리액트는 가상 DOM을 다루면서 이벤트도 별도로 관리한다. 리액트 컴포넌트(노드)에 등록되는 이벤트 리스너는 onClick, onChange 처럼 카멜 케이스로 표기한다.

type EventHandler<Event extends React.SyntheticEvent> = (e: Event) => void | null;
type ChangeEventHandler = EventHandler<ChangeEvent<HTMLSelectElement>>;
const eventHandler1: GlobalEventHandlers["onchange"] = (e) => {
e.target; // 일반 Event는 target이 없음
};
const eventHandler2: ChangeEventHandler = (e) => {
e.target; // 리액트 이벤트(합성 이벤트)는 target이 있음
};
const handleChange : React.ChangeEventHandler<HTMLSelectElement> = (e) => {
const selected = Object.entries (options).find(
([_, value]) => value === e.target.value
)?.[0];
onChange?.(selected);
};

5. 훅에 타입 추가하기

const fruits = {
apple: '사과',
banana: '바나나',
blueberry: '블루베리',
};

type Fruit = keyof typeof fruits;
const FruitSelect: VFC = () => {
const [fruit, changeFruit] = useState<string | undefined>();
return (
<Select
// Error - SetStateAction<undefined>와 맞지 않음
// (changeFruit에는 undefined만 매개변수로 넘길 수 있음)
onChange={changeFruit}
options={fruits}
selectedOption={fruit}
/>
);
}
// 에러 발생
const func = () => {
changeFruit('orange');
};

keyof typeof obj를 사용해 fruits 키값만 추출해서 Fruit라는 타입을 새로 만들었다. 해당 타입을 제네릭으로 활용해 다른 값이 할당되면 에러가 발생한다.

6. 제네릭 컴포넌트 만들기

위의 코드에서 제한된 키와 값을 가지게 만들기 위해 제네릭을 사용한 컴포넌트를 만들어야 한다.

interface SelectProps<OptionType extends Record<string, string>> {
options: OptionType;
selectedOption?: keyof OptionType;
onChange?: (selected?: keyof OptionType ) => void;
}
const Select = <OptionType extends Record<string, string>>({
options,
selectedOption,
onChange,
}: SelectProps<OptionType>) => {
// Select component implementation
};

객체 형식의 타입을 받아 해당 객체의 키값을selectedOption, onChange의 매개변수에만 적용하도록 만든 예시이다.

const fruits = {
apple: '사과',
banana: '바나나',
blueberry: '블루베리',
};
const FruitSelect: VFC = () => {
// ...
// <Select<Fruit > ... />으로 작성해도 되지만, 넘겨주는 props의 타입으로 타입 추론을 해줍니다
// Type Error - Type “orange ” is not assignable to type “apple ” | “banana ” |'blueberry' | undefined
return (
<Select options={fruits} onChange={changeFruit} selectedOption='orange' />
);
};

Select 컴포넌트에 전달되는 props의 타입 기반으로 타입이 추론되어<Select<추론된 타입>> 형태의 컴포넌트가 생성된다.

7. HTMLAttributes, ReactProps 적용하기

type ReactSelectProps = React.ComponentPropsWithoutRef<'select'>;
interface SelectProps<OptionType extends Record<string, string>> {
id?: ReactSelectProps['id'];
className?: ReactSelectProps['className'];
// ...
}

React.ComponentPropsWithoutRef를 통해 select의 props를 추출한다. ReactSelectProps 타입을 설정해 엘리면트에서 속성의 타입을 그대로 가져온다.

interface SelectProps<OptionType extends Record<string, string>> 
extends Pick<ReactSelectProps, 'id' | 'key' >
{
// ...
}

Pick<Type, ‘key1’ | ‘key2’ ...>는 객체 형식의 타입에서 key1, key2...의 속성만 추출하여 새로운 객체 형식의 타입을 반환한다. 여러 개의 타입을 가져와야 할 때 사용된다.

8. styled-components를 활용한 스타일 정의

CSS-in-JS 라이브러리인 styled-components를 사용해 직접 스타일을 적용할 수 있다.

export const theme = {
fontSize: {
default: "16px",
small: "14px",
large: "18px",
},
color: {
white: "#FFFFFF",
black: "#000000",
},
};

type Theme = typeof theme;
export type FontSize = keyof Theme["fontSize"];
export type Color = keyof Theme["color"];
interface SelectStyleProps {
color: Color;
fontSize: FontSize;
}
const StyledSelect = styled.select<SelectStyleProps>`
color: ${({ color }) => theme.color[color]};
font-size: ${({ fontSize }) => theme.fontSize [fontSize]};
`;
interface SelectProps extends Partial<SelectStyleProps> {
// ...
}
const Select = <OptionType extends Record<string, string>>({
fontSize = 'default',
color = 'black',
// ...
}: SelectProps<OptionType>) => {
// ...
return (
<StyledSelect
// ...
fontSize={fontSize}
color={color}
// ...
/>
);
};

9. 공변성과 반공변성

안전한 타입 가드를 위해서는 특수한 경우를 제외하고는 일반적으로 반공변적인 함수 타입을 설정하는 것이 권장된다. 함수 선언 형식을 사용하면 공변성을 띄고 화살표 함수로 선언하면 반공변성을 띈다.

1. 공변성

출력값에 적용되며,상위 타입이 하위 타입으로 대체될 수 있다는 개념이다. 구체적인 타입이 넓은 타입으로 반환될 수 있다.

interface Box<out T> {  // out: 공변성
value: T;
}

// Box<string>은 Box<Animal>을 대체할 수 있다
class Animal {}
class Dog extends Animal {}

const animalBox: Box<Animal> = { value: new Animal() };
const dogBox: Box<Dog> = { value: new Dog() };

// Box<Dog>는 Box<Animal>에 할당될 수 있다.
const anotherAnimalBox: Box<Animal> = dogBox;
  • T의 하위 타입 S가 있을 때, Box<T>는 Box<S>로 교환 가능해진다.

2. 반공변성

입력값(인자)에 적용되며, 상위 타입이 하위 타입으로 대체될 수 없다는 개념이다. 더 넓은 타입을 좁은 타입으로 처리할 수 없다.

interface Handler<in T> {  // in: 반공변성
handle(value: T): void;
}

// Handler<Dog>는 Handler<Animal>을 대체할 수 없다
class Animal {}
class Dog extends Animal {}

const animalHandler: Handler<Animal> = { handle: (value: Animal) => {} };
const dogHandler: Handler<Dog> = { handle: (value: Dog) => {} };

// 반공변성: Handler<Dog>는 Handler<Animal>에 할당할 수 없다
// const anotherAnimalHandler: Handler<Animal> = dogHandler; // 오류 발생
  • T의 하위 타입 S가 있을 때, Handler<S>는 Handler<T>로 교환할 수 없다.

작성하고 느낀 점

좋았던 점: ReactDeepDive 1장에서 단편적으로 타입스크립트를 리액트에 적용하는 방법을 배웠었는데 더 깊이있는 내용인 컴포넌트와 요소에 타입을 적용하고 특정 타입만을 가져오게 하거나 안전하게 받아오는 방법을 배웠다.

배운 점: 리액트에서 타입스크립트를 적용하는 방법에 대해 배웠다.

아쉬운 점: 내용을 압축한 형태라 예제를 보고 바로 이해할 수 없다. 이해를 돕기 위한 외부 자료를 찾는 데 시간을 소모했다.

향후 계획: 9장 10장 11장은 ReactDeepDive에서 더 자세하게 다뤘으니 12장 타입스크립트 프로젝트 관리 파트로 진행한다.