Skip to Content
WebTypeScriptTypeScript 실무05. 이벤트와 폼 타이핑

이번 편의 결과물: BookForm.tsxNewBookPage.tsx가 타입 있는 폼으로 전환되어, 입력·제출 핸들러에 타입 오류 없이 폼이 동작합니다. · 다루는 개념: ChangeEvent/FormEvent 타입, 제어 컴포넌트 상태 타입, react-hook-form register의 타입 자동 추론

04편까지 Header.tsx, BookCard.tsx가 타입 있는 props로 전환됐습니다. bookshelf-ts가 이어받은 bookshelf(react_3 완성 시점)의 책 등록 폼은 이미 react-hook-form(RHF)과 zod로 검증하도록 완성되어 있습니다. 이 편은 새 기능을 추가하지 않고, 그 폼 컴포넌트 두 개에 타입만 입힙니다.

이 편에서 만드는 파일

bookshelf-ts/src/ ├── schemas/ │ └── bookSchema.ts ~ .js → .ts 전환 (z.infer로 BookFormValues 도출) ├── components/ │ └── BookForm.tsx ~ .jsx → .tsx 전환 (props interface, 메모 글자 수 카운터 추가) └── pages/ └── NewBookPage.tsx ~ .jsx → .tsx 전환 (서버 에러 매핑 타입)

개념 정리

이벤트 타입은 요소마다 다르다

React의 합성 이벤트는 어떤 DOM 요소에서 발생했는지에 따라 제네릭 타입 인자가 달라집니다.

이벤트 타입언제 쓰는가
ChangeEvent<HTMLInputElement>텍스트·숫자 input 값이 바뀔 때
ChangeEvent<HTMLTextAreaElement>textarea 값이 바뀔 때
ChangeEvent<HTMLSelectElement>select 값이 바뀔 때
FormEvent<HTMLFormElement>폼이 제출될 때(event.preventDefault() 호출)

요소 타입을 맞춰 넘겨야 event.target.value의 타입이 정확해집니다. HTMLInputElementHTMLSelectElement 자리에 쓰면 실제로는 문제없이 컴파일되는 경우도 있지만, 그 요소만 갖는 속성(checked, selectedIndex 등)에 접근할 때 타입이 어긋나기 시작합니다.

zod 스키마 하나에서 폼 타입을 뽑는다

z.infer<typeof bookSchema>는 zod 스키마의 검증 결과 타입을 그대로 TypeScript 타입으로 만듭니다. 필드를 추가·삭제할 때 스키마 한 곳만 고치면 폼 타입도 함께 바뀝니다.

const bookSchema = z.object({ title: z.string().min(1) }) type BookFormValues = z.infer<typeof bookSchema> // { title: string }

register의 타입 자동 추론

useForm<BookFormValues>({ resolver: zodResolver(bookSchema) })처럼 useForm에 타입 인자를 주면, register('title')의 필드 이름 인자가 BookFormValues의 키로 제한됩니다. register('titl')처럼 오타를 내면 그 자리에서 컴파일 오류가 나 실행 전에 잡힙니다. formState.errors.title도 같은 타입 정보를 바탕으로 존재 여부가 좁혀집니다.

캐치 변수는 기본적으로 unknown이다

catch (error)error는 타입스크립트에서 기본적으로 unknown입니다. error.message처럼 바로 접근할 수 없고, error instanceof Error로 좁히거나 필요한 모양으로 캐스팅한 뒤 접근합니다. 아직 .js로 남은 api/books.js가 던지는 값처럼, 타입 정보가 없는 값을 다룰 때 특히 자주 마주칩니다.

실습

1. bookSchema를 TypeScript로 전환

// src/schemas/bookSchema.ts import { z } from 'zod' export const bookSchema = z.object({ title: z.string().min(1, '제목을 입력하세요').max(100, '제목은 100자 이하로 입력하세요'), author: z.string().min(1, '저자를 입력하세요'), pages: z.coerce.number().int('정수로 입력하세요').min(0, '0 이상이어야 합니다'), status: z.enum(['wish', 'reading', 'done']), memo: z.string().max(500, '메모는 500자 이하로 입력하세요').optional(), }) export type BookFormValues = z.infer<typeof bookSchema>

파일 확장자만 .ts로 바꿨을 뿐, 검증 규칙은 그대로입니다. export type BookFormValues가 이번 편에서 새로 추가한 유일한 줄입니다.

2. BookForm을 tsx로 전환하고 props 타입 지정

// src/components/BookForm.tsx import { useState, type ChangeEvent } from 'react' import { useForm, type UseFormSetError } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { bookSchema, type BookFormValues } from '../schemas/bookSchema' const COVER_COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#db2777', '#7c3aed', '#0891b2'] const MEMO_MAX_LENGTH = 500 export type BookCreatePayload = BookFormValues & { coverColor: string } export interface BookFormSubmitHelpers { setError: UseFormSetError<BookFormValues> } interface BookFormProps { defaultValues?: BookFormValues onSubmitBook: (values: BookCreatePayload, helpers: BookFormSubmitHelpers) => Promise<void> submitLabel?: string } function BookForm({ defaultValues, onSubmitBook, submitLabel = '저장' }: BookFormProps) { const { register, handleSubmit, setError, formState: { errors, isSubmitting }, } = useForm<BookFormValues>({ resolver: zodResolver(bookSchema), defaultValues: defaultValues ?? { title: '', author: '', pages: 0, status: 'wish', memo: '' }, }) const memoField = register('memo') const [memoLength, setMemoLength] = useState(defaultValues?.memo?.length ?? 0) function handleMemoChange(event: ChangeEvent<HTMLTextAreaElement>) { setMemoLength(event.target.value.length) memoField.onChange(event) } async function onValid(values: BookFormValues) { const coverColor = COVER_COLORS[Math.floor(Math.random() * COVER_COLORS.length)] try { await onSubmitBook({ ...values, coverColor }, { setError }) } catch { // onSubmitBook 쪽에서 setError로 이미 처리한다 } } return ( <form className="book-form" onSubmit={handleSubmit(onValid)} noValidate> <label htmlFor="book-title">제목</label> <input id="book-title" type="text" {...register('title')} /> {errors.title && <p className="field-error">{errors.title.message}</p>} <label htmlFor="book-author">저자</label> <input id="book-author" type="text" {...register('author')} /> {errors.author && <p className="field-error">{errors.author.message}</p>} <label htmlFor="book-pages">쪽수</label> <input id="book-pages" type="number" {...register('pages')} /> {errors.pages && <p className="field-error">{errors.pages.message}</p>} <label htmlFor="book-status">상태</label> <select id="book-status" {...register('status')}> <option value="wish">읽고 싶어요</option> <option value="reading">읽는 중</option> <option value="done">다 읽음</option> </select> <label htmlFor="book-memo">메모</label> <textarea id="book-memo" {...memoField} onChange={handleMemoChange} maxLength={MEMO_MAX_LENGTH} /> <p className="field-hint"> {memoLength} / {MEMO_MAX_LENGTH}자 </p> {errors.memo && <p className="field-error">{errors.memo.message}</p>} <button type="submit" disabled={isSubmitting}> {isSubmitting ? '저장 중...' : submitLabel} </button> </form> ) } export default BookForm

memoFieldregister('memo')가 반환하는 { name, onChange, onBlur, ref } 객체입니다. textarea에 그대로 펼치는 대신 onChangehandleMemoChange로 덮어써, 글자 수를 세는 로컬 상태(memoLength)와 RHF의 원래 onChange를 함께 호출합니다. event의 타입이 ChangeEvent<HTMLTextAreaElement>라서 event.target.value가 정확히 문자열로 좁혀집니다.

onSubmitBookBookCreatePayload(폼 값 + coverColor)를 받는 함수로 타입을 명시했습니다. 이 타입과 BookFormSubmitHelpersexport해 두면 NewBookPage.tsx가 같은 타입을 그대로 가져다 씁니다.

3. NewBookPage를 tsx로 전환

// src/pages/NewBookPage.tsx import { useNavigate } from 'react-router' import BookForm, { type BookCreatePayload, type BookFormSubmitHelpers } from '../components/BookForm' import { useCreateBook } from '../hooks/useBookMutations' import type { BookFormValues } from '../schemas/bookSchema' interface ServerFieldError { fieldErrors?: Partial<Record<keyof BookFormValues, string>> } function NewBookPage() { const navigate = useNavigate() const createBook = useCreateBook() async function handleSubmitBook(values: BookCreatePayload, { setError }: BookFormSubmitHelpers) { try { await createBook.mutateAsync(values) navigate('/') } catch (error) { const fieldErrors = (error as ServerFieldError).fieldErrors ?? {} const fields = Object.keys(fieldErrors) as (keyof BookFormValues)[] if (fields.length === 0) { setError('root', { message: '저장에 실패했습니다. 잠시 후 다시 시도하세요.' }) return } fields.forEach((field) => { const message = fieldErrors[field] if (message) { setError(field, { type: 'server', message }) } }) } } return ( <section> <h2>책 등록</h2> <BookForm onSubmitBook={handleSubmitBook} submitLabel="등록" /> </section> ) } export default NewBookPage

useCreateBook은 아직 .js로 남은 hooks/useBookMutations.js가 내보냅니다. 인자·반환값에 명시적 타입이 없어 createBook.mutateAsync(values) 호출은 지금은 느슨하게 통과합니다. 이 훅에 정식으로 타입이 붙는 시점은 12편(TanStack Query 타이핑)입니다. 지금은 BookForm·NewBookPage 사이의 경계만 엄격하게 지키는 것이 이 편의 목표입니다.

catch (error)errorunknown이라 바로 error.fieldErrors에 접근할 수 없습니다. api/books.jscreateBookObject.assign(new Error(...), { fieldErrors }) 형태로 던진다는 것을 알고 있으므로, ServerFieldError 모양으로 캐스팅한 뒤 접근합니다.

4. router.jsx의 import 경로 확인

router.jsx는 아직 자바스크립트 파일이라 이번 편의 대상이 아닙니다. import NewBookPage from './pages/NewBookPage.jsx'처럼 확장자를 명시해 가져오고 있었다면, 파일이 .tsx로 바뀐 지금 이 한 줄만 .tsx로 맞춰 둡니다. 라우터 자체를 정식으로 전환하는 작업은 08편에서 이어집니다.

5. 실행

npm run dev

6. 확인

  • 책 등록 폼(/books/new)에서 제목·저자를 비운 채 제출하면 입력창 아래 에러 메시지가 그대로 뜹니다.
  • 메모 칸에 타이핑하면 입력창 아래 글자 수 / 500자가 실시간으로 갱신됩니다.
  • VS Code에서 register('titl')처럼 존재하지 않는 필드 이름을 넣어 보면 빨간 밑줄과 함께 오류가 표시됩니다. 확인 후 원래대로 되돌립니다.
  • db.json에 이미 있는 제목으로 등록을 시도하면 제목 입력창 아래에 서버 에러 메시지가 표시됩니다.

직접 해보기

  1. bookSchemarating: z.coerce.number().int().min(0).max(5) 필드를 추가하고, BookFormValues에 자동으로 rating: number가 포함되는지 확인해 보세요. BookFormdefaultValues에도 rating: 0을 추가해야 초기 렌더 경고가 나지 않습니다.
  2. formState.errors.root가 있을 때 폼 상단에 공통 에러 배너를 렌더링해 보세요.

정답 보기

// src/components/BookForm.tsx (return 안 최상단에 추가) {errors.root && <p className="form-error-banner">{errors.root.message}</p>}

rating 필드를 추가하면 BookFormValues가 스키마로부터 자동으로 다시 계산되므로, BookForm이나 NewBookPage의 타입 선언을 따로 손댈 필요가 없습니다. defaultValues처럼 실제 객체를 만드는 곳에서만 새 필드를 채워주면 됩니다.

자주 하는 실수

증상원인고치는 법
텍스트 입력창인데 이벤트 타입에서 checked 속성을 찾는 오류ChangeEvent의 제네릭을 실제 요소와 다르게(예: 체크박스용 타입) 지정함요소 종류에 맞는 HTMLInputElement·HTMLTextAreaElement·HTMLSelectElement를 정확히 지정한다
error.message에서 속성이 없다는 오류catch (error)errorunknown인데 바로 속성 접근instanceof Error로 좁히거나 알고 있는 모양으로 캐스팅한다
register('titl')처럼 오타를 냈는데 오류가 안 남useForm에 타입 인자(useForm<BookFormValues>)를 안 줌useForm 호출에 반드시 폼 값 타입을 명시한다
메모 글자 수가 갱신되지 않음textareaonChange를 덮어쓰면서 memoField.onChange(event) 호출을 빠뜨림RHF가 값 추적을 계속하도록 원래 onChange도 함께 호출한다

확인 문제

문제 14지선다
BookFormValues 타입을 z.infer로 도출하는 이유로 가장 알맞은 것은?
문제 24지선다
useForm에 타입 인자로 BookFormValues를 명시했을 때 얻는 효과는?
문제 34지선다
catch (error)의 error가 기본적으로 unknown 타입인 이유와 대처 방법으로 알맞은 것은?
문제 44지선다
이 편에서 useCreateBook 호출부(createBook.mutateAsync(values))에 엄격한 타입 검사가 아직 적용되지 않는 이유는?

참고 자료

Last updated on