Skip to content
This page has been auto-translated and may contain errors.View in English

React에서 TypeScript 사용하기

부모 컴포넌트가 prop 이름을 word에서 currentWord로 바꾸었습니다. 자식 컴포넌트 3개는 여전히 word를 읽고 있고, 아무것도 이를 지적하지 않습니다. 앱은 빌드되고, 페이지는 로드되며, 그 자식 컴포넌트 중 하나를 렌더링하는 버튼을 클릭한 첫 번째 사용자가 빈 화면을 보게 됩니다. TypeScript는 JavaScript 위에 타입 레이어를 추가합니다. 모든 값은 선언되거나 추론된 타입을 가지며, 컴파일러는 코드가 실행되기 전에 타입이 맞지 않는 부분을 지적합니다.

React에서 이것은 데이터가 컴포넌트 사이를 이동하는 경계, 바로 그런 버그가 숨어 있는 곳에서 효과를 발휘합니다. 타입 지정된 prop은 계약입니다. 부모는 올바른 형태를 보내야 하고, 자식은 도착한 것을 신뢰할 수 있으며, 편집기는 양쪽 모두를 자동 완성해줍니다.

이 장에서는 React 관련 부분을 다룹니다. 상태, props, children, 함수 props, 컴포넌트 반환값을 타입 지정하는 방법을 설명합니다. 이 장은 TypeScript의 기초, union 타입, 커스텀 타입, 제네릭을 이미 알고 있다고 가정합니다. 이 섹션의 처음 부분에서 이 모든 것을 다시 한 번 다루고, 그 다음 Assembly: Endgame 게임을 TypeScript로 다시 만들면서 모든 것을 실무에 적용합니다.

설정: Vite의 react-ts 템플릿

Vite는 React 템플릿의 TypeScript 버전을 제공합니다. 프로젝트 생성 시 한 가지 플래그만 있으면 완벽하게 설정된 프로젝트를 얻을 수 있습니다.

bash
npm create vite@latest my-react-app -- --template react-ts

컴포넌트 파일은 .jsx 대신 .tsx 확장자를 사용하고 (TypeScript와 JSX), JSX가 없는 일반 모듈은 .js 대신 .ts를 사용합니다. 그 외 설정에 관한 모든 것은 항상 그대로 작동합니다. Vite는 타입을 제거하고 순수한 JavaScript를 브라우저로 보냅니다.

여기서 한 가지 주의할 점이 있습니다. 타입 오류는 편집기에 표시되고 번들링 전에 tsc -b를 실행하는 npm run build를 실패시킵니다. 하지만 개발 서버는 타입을 검사하지 않고 제거합니다. npm run dev는 타입 오류가 가득한 프로젝트도 서빙할 수 있으므로 실행되는 앱이 타입이 통과한다는 증거가 아닙니다.

상태 타입 지정하기

useState는 보통 자동으로 타입이 지정됩니다. hook은 전달하는 초기값에서 타입을 추론하므로, 실제 시작 값으로 생성된 상태는 주석이 필요하지 않습니다.

tsx
const [currentWord, setCurrentWord] = useState(getRandomWord())

getRandomWord가 문자열을 반환하면, currentWordstring이고 setCurrentWord는 문자열만 받습니다. setCurrentWord(true)를 호출하면 TypeScript는 즉시 이를 지적합니다. boolean은 문자열이 예상되는 곳에 할당할 수 없습니다. 이것이 추론이 제대로 작동하는 것입니다. 대부분의 상태에서는 그냥 두면 됩니다.

추론이 실패하는 경우는 초기값이 빈 값이거나 상태의 미래 형태를 설명하기에 너무 느슨할 때입니다. 빈 배열은 그것이 무엇을 보관할지 말하지 않으므로 useState([])never[]를 추론합니다. 요소가 절대 아무것도 될 수 없는 배열이므로, 그것에 대한 모든 push는 오류입니다. 이것이 명시적 형태를 사용하는 곳입니다. useState는 제네릭 함수이고, 타입 인자를 꺾쇠괄호로 전달합니다.

tsx
const [guessedLetters, setGuessedLetters] = useState<string[]>([])

이제 초기값이 빈 배열이더라도 상태는 문자열의 배열이며, 값과 setter 모두 이를 강제합니다. 같은 방식은 값이 null로 시작하고 나중에만 실제 값이 되는 nullable 상태도 다룹니다.

tsx
type Word = { text: string; difficulty: number }

const [selectedWord, setSelectedWord] = useState<Word | null>(null)

union Word | null은 TypeScript에 이 상태가 법적으로 보관할 수 있는 두 형태를 알려줍니다. 이제 selectedWord를 읽을 때마다 .text를 건드리기 전에 null 케이스를 처리하도록 강제됩니다. 이것은 전형적인 런타임 충돌을 컴파일 타임의 알림으로 바꿉니다. 상태의 메커니즘 자체는 변하지 않습니다. TypeScript는 상태가 보관할 수 있는 내용을 고정할 뿐입니다.

컴포넌트 props 타입 지정하기

Props는 하나의 객체로 도착하므로, 타입을 지정하는 것은 그 객체에 주석을 다는 것입니다. prop이 1개나 2개인 컴포넌트의 경우, 인라인 주석이 잘 작동합니다.

tsx
function ConfettiContainer({ isGameWon }: { isGameWon: boolean }) {
  // ...
}

Props가 늘어나면 인라인 타입은 복잡해집니다. 표준 패턴은 명명된 **타입 별칭**이며, 관례상 ComponentNameProps로 불리며, 컴포넌트 위에 선언됩니다.

tsx
type GameStatusProps = {
  isGameWon: boolean
  wrongGuessCount: number
  message?: string
}

function GameStatus({ isGameWon, wrongGuessCount, message }: GameStatusProps) {
  // ...
}

message?는 선택 사항임을 표시합니다. 부모는 생략할 수 있으며, 컴포넌트 내부에서 타입은 string | undefined입니다. interface GameStatusProps { ... } 선언은 정확히 같은 위치에서 작동합니다. props를 타입 지정하는 것에 관해서는 둘이 상호 교환 가능하며, 이 과정에서는 다른 곳에서 구축하는 커스텀 타입과의 일관성을 위해 타입 별칭을 고수합니다.

컴포넌트가 children을 받으면, ReactNode로 타입 지정합니다. React가 렌더링할 수 있는 모든 것을 포함하는 넓은 타입입니다. 요소, 문자열, 숫자, fragment, 이들의 배열 등입니다.

tsx
import { type ReactNode } from 'react'

type CardProps = {
  title: string
  children: ReactNode
}

children이 컴포넌트를 통해 어떻게 흐르는지는 Children and composition에서 다룹니다. ReactNode는 이들을 설명하는 타입입니다. props가 런타임에 동작하는 방식에 관한 모든 것은 같습니다. 타입 주석은 위에 계약을 추가합니다.

함수 props 타입 지정하기

컴포넌트는 종종 함수를 props로 받습니다. 클릭 핸들러, 값을 위쪽으로 보고하는 콜백이 그 예입니다. 함수 prop의 타입은 화살표 문법을 사용합니다. 매개변수 목록과 그 타입, 화살표, 그리고 반환 타입입니다.

tsx
type NewGameButtonProps = {
  startNewGame: () => void
}

type LetterButtonProps = {
  letter: string
  onGuess: (value: string) => void
}

() => void는 인자를 받지 않고 아무것도 반환하지 않는 함수의 형태를 설명합니다. 대부분의 이벤트 스타일 핸들러가 이 형태입니다. (value: string) => void는 자식이 문자열로 함수를 호출할 것이므로 부모의 구현이 하나를 받아야 한다고 말합니다. 매개변수 타입이 일치하지 않는 핸들러를 전달하면 오류가 JSX 호출 부분, 부모에서, 컴파일 타임에 나타납니다.

하지만 체크는 두 가지를 허용합니다. 그 둘 다 타입 체커가 뭔가 놓친 것처럼 보입니다. 핸들러는 타입이 나열하는 것보다 더 적은 매개변수를 선언할 수 있습니다. 따라서 onGuess={() => setOpen(true)}(value: string) => void를 만족합니다. 그리고 () => void는 값을 반환하는 함수를 받으므로 startNewGame={async () => { await saveScore() }}는 아무것도 기다리지 않는 promise를 넘기더라도 타입 체크를 통과합니다. 컴파일러는 형태의 모든 차이가 아니라 잘못된 매개변수 타입을 포착합니다.

반환 타입과 파생 값

함수 컴포넌트 위에 마우스를 올리면 TypeScript는 이미 그것이 React.JSX.Element를 반환한다고 알고 있습니다. 반환 문의 JSX에서 추론됩니다. (JSX namespace는 이제 전역이 아니라 react 모듈 내부에 있으므로, 손으로 주석을 작성하는 것은 이를 임포트하는 것을 의미합니다.)

이 추론을 그냥 두어도 되며, 많은 코드베이스가 그렇게 합니다. 반환 타입을 주석 다는 것은 엄격함에 대한 선택입니다. 컴포넌트가 항상 단일 요소를 반환한다는 것을 보장합니다. 어떤 팀은 더 큰 코드베이스에서 이를 중요하게 생각합니다. 그것은 React가 하는 약속보다 더 엄격합니다. 컴포넌트는 합법적으로 문자열, 숫자, 배열, 또는 null을 반환할 수 있기 때문입니다. 그리고 단순히 JSX.Element 주석은 이것을 넓혀서 그것들을 허용할 때까지 모두 거절합니다.

tsx
import { type JSX } from 'react'

function Header(): JSX.Element {
  return <h1>Assembly: Endgame</h1>
}

function ConfettiContainer({ isGameWon }: { isGameWon: boolean }): JSX.Element | null {
  if (!isGameWon) return null
  return <Confetti />
}

ConfettiContainer는 때때로 null을 반환함으로써 아무것도 렌더링하지 않습니다. 이것은 조건부 렌더링에서 사용된 같은 방식입니다. 따라서 주석 달린 반환 타입은 union JSX.Element | null입니다. 같은 습관은 컴포넌트 내부에서 파생된 값으로 확장됩니다. 화살표 함수의 반환 타입은 그 매개변수 괄호 뒤에 붙으며, JSX를 생성하는 데이터 위의 mapJSX.Element[]를 생성합니다. 이 주석의 대부분은 추론이 이미 결론 지은 것을 다시 말할 뿐입니다. 일관되게 보상하는 것들은 경계에 앉습니다. props, 빈 또는 nullable 상태, 그리고 다른 컴포넌트가 의존하는 서명을 가진 함수입니다.

공유 타입 임포트하기

타입은 다른 바인딩처럼 내보내고 임포트되며, 이것은 한 정의가 전체 앱을 처리하도록 합니다. 데이터 모듈은 일반적으로 데이터와 함께 그 형태를 내보냅니다.

tsx
// languages.ts
export type Language = {
  name: string
  backgroundColor: string
  color: string
}

// LanguageChips.tsx
import { type Language } from './languages'

type LanguageChipsProps = {
  languages: Language[]
}

임포트의 type 키워드는 그것을 타입 전용으로 표시하므로 컴파일된 JavaScript에서 완전히 사라집니다. Language같은 타입이 한 곳에 살면, 그 데이터를 건드리는 모든 컴포넌트는 같은 계약을 임포트합니다. 한 파일에서 형태를 변경하면 업데이트가 필요한 모든 호출 부분에서 표면화됩니다.

이벤트 객체는 타입이 가장 먼저 문제가 되는 곳입니다. 인라인 핸들러는 이벤트 타입을 무료로 얻습니다. React가 <input>onChange로 전달하는 것을 알기 때문입니다. onChange={event => setGuess(event.target.value)}event는 이미 타입이 지정되어 있습니다. 그 핸들러를 명명된 함수로 추출하면 컨텍스트가 사라집니다. 매개변수가 암묵적인 any가 되고, 템플릿의 strict 타입 체크는 그것을 오류로 만듭니다. 요소와 일치하는 React 이벤트 타입으로 주석을 달아야 합니다.

tsx
function GuessInput() {
  const [guess, setGuess] = useState('')

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setGuess(event.target.value)
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setGuess('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={guess} onChange={handleChange} />
    </form>
  )
}

꺾쇠괄호의 요소 타입이 event.target.valuestring으로 만드는 것입니다. 대신 HTMLElement를 거기에 넣으면 targetvalue를 전혀 갖지 않습니다. 같은 형태는 나머지를 다룹니다. React.MouseEvent<HTMLButtonElement>는 클릭을 위해, React.KeyboardEvent<HTMLInputElement>는 키 누르기를 위해. 각각은 import { type ChangeEvent } from 'react'처럼 이름으로도 임포트 가능합니다. 핸들러가 런타임에 하는 것은 eventsforms 영역입니다. 주석은 React가 그것들에 넘기는 것의 이름일 뿐입니다.

더 오래된 React + TypeScript 코드는 컴포넌트를 const Header: React.FC = () => ...로 타입 지정합니다. React.FC는 반환값이 아니라 전체 함수에 주석을 달며, 구체적인 이유로 인해 선호도가 떨어졌습니다. 역사적으로 컴포넌트가 받은 것과 관계없이 조용히 모든 컴포넌트의 props에 children을 추가했으며, 제네릭 컴포넌트를 복잡하게 합니다. 현재 관례는 이 장이 사용하는 것입니다. 타입 지정된 props와 추론되거나 주석 달린 JSX.Element 반환이 있는 순수한 함수입니다.

타입을 다시 선언하기보다는 파생하는 것을 선호합니다. 컴포넌트가 기존 형태의 일부가 필요할 때, 유틸리티 타입은 단일 출처를 유지합니다. 칩의 인라인 스타일 객체가 색상 필드를 담는 것은 Pick<Language, 'backgroundColor' | 'color'>입니다. Pick은 화이트리스트입니다. 따라서 나중에 Language에 추가된 필드는 style에 넘기는 객체에 들어올 수 없습니다. 반대 작업에 Omit을 사용합니다. props 타입에서 알려진 키를 제거합니다. 출처 타입이 성장하는 것을 상속받는 것이 원하는 동작입니다.

같은 본능은 DOM 중심 wrapper에 적용됩니다. React의 ComponentProps<'button'>은 native button이 받는 모든 prop을 끌어오므로 design-system button은 onClick, disabled, 그 친구들을 손으로 나열하는 대신 이를 확장할 수 있습니다.

각 주석 정책은 자기 방식대로 실패하며, 이것이 계획할 가치가 있는 부분입니다. 모든 것에 주석을 달면 모든 리팩토링이 추론이 이미 알고 있던 것을 다시 말할 뿐인 파일 전체의 diff를 끌어갑니다. 그러면 그 주석들은 더 이상 갖지 않는 형태를 설명하기 시작하고 코드 뒤에 떨어집니다. 경계만 주석을 달면 나쁜 추론, 타입 지정되지 않은 의존성에서 새어 나가는 any가 조용히 로컬 값을 통해 퍼져 나갑니다. 이것은 그것을 거절하는 경계를 만날 때까지 입니다.

두 번째 실패는 포착하기가 더 저렴합니다. 경계는 주석이 이미 앉아 있는 곳이기 때문입니다. 주석-경계라는 기본값을 만들고, 파생 값의 추론 타입이 놀랄 때마다 로컬 주석을 추가합니다.

Juno경계를 타입 지정하고 나머지는 추론하기 TypeScript는 컴포넌트 사이에 전달하는 상자에 라벨을 붙이는 것과 같습니다.

실제 시작값으로 생성된 상태는 자신의 라벨을 붙이며, 시작값이 빈 값이거나 null이면 useState&lt;string[]&gt;([])처럼 손으로 라벨을 작성합니다. Props는 각 prop과 그 타입을 나열하는 GameStatusProps같은 작은 명명된 타입을 얻습니다.

라벨이 붙으면 편집기는 앱을 실행하기 전에 잘못된 형태의 뭔가가 전달되는 순간 경고합니다.

Juno경계를 타입 지정하고 나머지는 추론하기 실제 초기값에서 useState가 추론하도록 하고, useState&lt;Word | null&gt;(null)처럼 빈 값과 nullable 케이스에 제네릭을 전달합니다.

각 컴포넌트에 ComponentNameProps 별칭을 제공하고, 선택 사항을 ?로 표시하며, children을 ReactNode로 타입 지정하고, (value: string) =&gt; void같은 서명으로 함수 props를 작성합니다. 추출된 이벤트 핸들러는 인라인 핸들러만 무료로 그 타입을 받으므로 React.ChangeEvent&lt;HTMLInputElement&gt; 같은 매개변수가 필요합니다.

import { type Language }로 데이터를 소유한 모듈에서 타입을 내보내고 임포트해서 타입을 공유합니다.

Juno경계를 타입 지정하고 나머지는 추론하기 공개 표면, props와 내보낸 서명에 주석을 달고, 프라이빗 파생 값은 추론이 처리하도록 합니다.

React.FC를 건너뛰고 타입 지정된 props와 보장을 원할 때의 JSX.Element 또는 JSX.Element | null 반환이 있는 순수 함수를 선호합니다. 복제하는 대신 파생합니다. PickComponentProps&lt;'button'&gt;은 단일 출처를 유지하므로 형태 변경이 영향을 받는 모든 호출 부분에서 컴파일 오류로 표면화됩니다.

이것이 핸드북입니다. 마크업을 반환하는 함수로 시작한 것은 이제 완전한 툴킷입니다. 컴포넌트와 상태, effect와 데이터, 재사용 가능한 컴포넌트 패턴, 라우팅, 성능에 대해 생각할 렌더링 모델, 그리고 누구든 다른 사람을 만나기 전에 경계에서 실수를 포착하는 타입 레이어입니다. 실제로 존재하기를 원하는 프로젝트를 고르고, npm create vite@latest로 스캐폴드하고, 프로젝트가 요청하기 시작할 때 이 장들을 다시 엽니다.