모든 글

TypeScript 타입은 빌드되면 사라진다 — 그래서 Zod로 런타임에 검증한다

as User로 "이건 User야"라고 우겼더니, 서버가 다른 걸 줬을 때 엉뚱한 화면에서 터졌다. 타입은 컴파일하면 지워진다는 사실과, 경계(API·localStorage·폼)에서 Zod로 실제 확인하는 법을 정리한다.

판별 유니온까지 오면 “타입을 잘 짜면 안전하다”는 감각이 생겨요.

그런데 여기 큰 구멍이 하나 있어요.

TypeScript 타입은 컴파일하는 순간 전부 지워져요(type erasure). 브라우저가 실제로 실행하는 건 순수 JS라, interface User는 런타임에 존재조차 하지 않아요.

퍼블리싱으로 치면 <input required>를 걸어놨어도 개발자도구로 속성을 지우고 보내버리면 그만인 거랑 같아요.

서버가 클라이언트를 안 믿듯이, 내 코드도 바깥에서 들어온 데이터는 안 믿어야 해요.

그런데 as는 이 사실을 숨겨요.

async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  return (await res.json()) as User;   // 서버가 null·에러객체·오타 필드를 줘도 통과
}
// → user.profile.name 에서 "Cannot read properties of undefined" 폭발.
//   스택은 화면 컴포넌트를 가리키지만, 진짜 범인은 fetch 경계다.

as User는 검증이 아니라 “이건 User라고 우기기”예요.

컴파일은 통과하지만 런타임엔 아무 보장이 없어요.

그래서 진짜 문제가 터진 자리(데이터가 들어온 곳)와 에러가 보이는 자리(화면)가 어긋나서 원인 추적이 지옥이 돼요.

그래서 API 응답·URL 쿼리·localStorage·폼 입력 같은 경계에서는, as로 우기는 대신 스키마 검증기로 런타임에 실제로 확인해요.

import { z } from 'zod';

// 스키마를 "진실의 원본"으로 두고, 타입은 거기서 파생
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'member']),   // 판별 유니온과도 그대로 연결된다
});
type User = z.infer<typeof UserSchema>;   // 타입을 손으로 또 안 적는다

async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  const parsed = UserSchema.safeParse(await res.json());

  if (!parsed.success) {
    console.error(parsed.error.issues);   // 데이터가 들어온 자리에서 즉시 실패
    throw new Error('API 응답 형식이 계약과 다릅니다');
  }
  return parsed.data;   // 여기서부터 User임이 런타임까지 보장됨
}

핵심은 z.infer로 스키마 하나에서 타입까지 뽑아쓴다는 거예요.

타입과 검증 로직이 따로 놀다 어긋나는 일이 없어져요.

실패 지점도 화면이 아니라 fetch 경계라, 배포 후 사용자 화면이 아니라 개발 중 콘솔에서 먼저 터지고요.

“그럼 if (data instanceof User) 하면 되잖아?”

싶은데 안 돼요.

type·interface는 컴파일하면 사라져서 런타임에 그런 이름의 값이 아예 없어요.

instanceof는 런타임 연산자라 오른쪽에 실제 존재하는 값(생성자 함수)이 와야 하거든요.

검증하려면 검증 로직이라는 “값”이 필요하고, Zod 스키마가 바로 그 값이에요.

localStorage에서도 같은 함정에 빠져요.

const raw = localStorage.getItem('posts');
const posts = JSON.parse(raw!) as Post[];   // 문제 두 개

raw!는 non-null 단언이에요.

키가 없으면 getItem은 null, JSON.parse(null)은 null을 돌려주고, 그 뒤 .map에서 터져요.

!는 “없을 리 없다”는 개발자의 주장일 뿐 TS는 검증하지 않아요.

그리고 as Post[]는 스키마를 만들어놓고 정작 검증에 안 쓴 거고요.

localStorage 값은 옛 버전이 저장한 구조거나 사용자가 편집한 값일 수도 있잖아요.

const raw = localStorage.getItem('posts');
if (!raw) return [];                                  // 없을 수 있음을 코드로 인정
const parsed = PostSchema.array().safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : [];             // 깨진 데이터는 빈 목록으로 흡수

풀스택으로 API까지 만들면 프론트·백 사이 계약이 어긋나는 순간(필드명 변경, nullable 추가)이 반드시 와요.

경계에서 검증하면 그게 사용자 화면이 아니라 콘솔에서 먼저 터져요.

같은 스키마를 폼 검증과 서버 입력 검증에 그대로 재사용할 수 있는 건 덤이고요.

as와 !는 타입 검사를 끄는 스위치예요.

경계에서 이 둘이 보이면, 대신 검증을 넣을 자리라는 신호로 읽으세요.

N
NACRE
Writing on less · Seoul
다른 글 보기

댓글

  1. 불러오는 중…