TypeScript로 React 계약 표현하기

props·이벤트·상태를 타입으로 설명하고 외부 JSON을 런타임에서 검증하기

타입은 실행 전에 맺는 계약이다

JavaScript로 동작을 설명할 수 있게 된 뒤, TypeScript로 잘못된 조합을 일찍 찾습니다. 타입은 API 응답을 정제하거나 브라우저에서 검증해 주는 기능이 아닙니다. 코드를 실행하기 전에 확인하는 계약이며 빌드된 JavaScript에서는 지워집니다.

이 문서의 결과물은 타입이 있는 검색 목록입니다. 앞선 React 예제의 props·상태·이벤트를 설명하고 외부 JSON이 잘못되면 명확히 실패시키는 경계를 만듭니다.

먼저 알아둘 다섯 가지

표현이 실습에서의 예
string, number기본 값의 종류이름, ID
type 또는 interface객체 구조Member
\`"frontend" \\"design"\`허용 값의 집합
name?: string속성이 없을 수도 있음선택 속성
unknown확인 전에는 사용할 수 없는 값JSON 응답

any는 검사를 건너뛰게 합니다. 모르는 입력을 다룰 때는 unknown으로 받고 조건으로 좁힙니다. as Member[]는 개발자가 타입을 단언할 뿐 실제 값을 검사하지 않습니다.

실행 환경

기존 JavaScript 실습을 보관하고 별도의 브랜치나 연습 폴더에서 TypeScript 템플릿을 만듭니다.

Shell
npm create vite@latest lion-track-ts -- --template react-tscd lion-track-tsnpm installnpm run dev

템플릿의 src/main.tsx는 유지하고 아래 파일들을 작성합니다. 초기 템플릿의 샘플 스타일 때문에 결과가 가려지면 src/index.css와 App의 기본 스타일 import를 정리합니다.

외부 데이터 검증과 타입 좁히기

src/members.ts입니다. 검증 함수가 성공해서 반환한 값만 Member로 사용합니다.

TypeScript
export type Member = {  id: number;  name: string;  track: "frontend" | "design";};
export function parseMembers(value: unknown): Member[] {  if (!Array.isArray(value)) throw new Error("목록 형식이 아닙니다.");  const ids = new Set<number>();  return value.map((item: unknown, index) => {    if (typeof item !== "object" || item === null      || !("id" in item) || typeof item.id !== "number"      || !Number.isSafeInteger(item.id) || item.id < 1      || !("name" in item) || typeof item.name !== "string"      || item.name.trim().length === 0      || !("track" in item)      || (item.track !== "frontend" && item.track !== "design")) {      throw new Error("잘못된 구성원: " + (index + 1) + "번째");    }    if (ids.has(item.id)) throw new Error("중복 ID: " + item.id);    ids.add(item.id);    return { id: item.id, name: item.name.trim(), track: item.track };  });}

실습에서는 직접 검사해 narrowing을 관찰합니다. 여러 화면에서 복잡한 스키마를 공유하게 되면 팀이 이미 쓰는 검증 라이브러리로 옮길 수 있습니다.

props·이벤트·상태 연결하기

src/App.tsx 전체 예제입니다.

TypeScript
import { useState } from "react";import { parseMembers, type Member } from "./members";
const members = parseMembers([  { id: 1, name: "아기사자 A", track: "frontend" },  { id: 2, name: "아기사자 B", track: "design" },]);
function MemberCard({ member }: { member: Member }) {  return <li>{member.name} · {member.track}</li>;}
export default function App() {  const [query, setQuery] = useState("");  const filtered = members.filter(member => member.name.includes(query));  return (    <main>      <h1>구성원 검색</h1>      <label htmlFor="query">이름</label>      <input id="query" value={query}        onChange={event => setQuery(event.currentTarget.value)} />      <p aria-live="polite">{filtered.length}</p>      <ul>{filtered.map(member => <MemberCard key={member.id} member={member} />)}</ul>    </main>  );}

문자열 초기값은 string으로 추론되므로 모든 useState에 제네릭을 붙일 필요가 없습니다. 빈 배열처럼 원소 타입을 추론할 근거가 없을 때는 useState<Member[]>([])로 의도를 지정합니다. JSX 안의 이벤트도 추론됩니다. 핸들러를 별도 함수로 뺄 때는 React의 이벤트 타입을 명시할 수 있습니다.

모순 없는 요청 상태

다음은 설계용 타입 예시이며 위 App에 추가할 필수 코드는 아닙니다.

TypeScript
type LoadState =  | { status: "loading" }  | { status: "error"; message: string }  | { status: "success"; data: Member[] };

loading인데 error message와 data가 동시에 남아 있는 조합을 없앱니다. status가 success인지 확인한 분기에서만 data를 읽게 하면 누락된 처리가 드러납니다. 빈 목록은 success의 data가 빈 배열인 경우입니다.

세션 실습과 확인

90분 동안 20분은 JavaScript 예제에 타입 달기, 30분은 실행과 props 변경, 25분은 나쁜 JSON 실험, 15분은 리뷰에 사용합니다.

  1. MemberCard에 문자열을 member로 넘겨 봅니다. npm run build에서 오류가 나야 합니다. 확인 후 정상 코드로 되돌립니다.
  2. 데이터의 id를 "1"로 바꿉니다. 런타임 검증이 거부하는 이유를 설명합니다.
  3. 이름이 공백, 중복 ID, 알 수 없는 track, 빈 배열을 각각 확인합니다.
  4. optional 소개 필드를 추가하고 값이 없는 화면을 설계합니다. non-null assertion !로 검사를 숨기지 않습니다.

완료 증거는 정상 빌드 결과, 잘못된 입력 세 가지의 오류 기록, “타입 오류”와 “런타임 검증 실패”를 구분한 PR 설명입니다.

더 읽어보기

연결된 PBL 미션과 VOD

주차·미션참고 VOD 범위
8주 · TypeScript리액트 실무 6·7·8장

7–9주의 VOD 표시는 외부 상호작용·서버 연동·앱 제작 범위를 함께 안내합니다. Router·TypeScript·Supabase의 세부 API는 각 공식 문서로 보완합니다.

강좌 안내: 참고 강좌 1 · 참고 강좌 2. 이 문서는 영상 전체를 옮긴 전사 자료가 아닙니다. 세부 내용은 위 공식 문서에서 확인합니다.