변환기는 샘플 값으로 초안을 만듭니다. 아래 수동 수정에는 API 계약에 대한 지식이 필요하며, 추론된 타입은 런타임 데이터를 검증하지 않습니다.
기본부터 시작하겠습니다. JSON 객체가 주어졌을 때, 어떻게 TypeScript 인터페이스로 변환할까요?
API에서 반환된 간단한 사용자 응답입니다:
{
"id": 42,
"username": "jdoe",
"email": "[email protected]",
"isActive": true
}
대응하는 TypeScript 인터페이스:
interface User {
id: number;
username: string;
email: string;
isActive: boolean;
}
각 JSON 값은 TypeScript 프리미티브에 매핑됩니다: number, string, 또는 boolean. 객체 키는 속성 이름이 됩니다.
실제 API는 평면 구조를 거의 반환하지 않습니다. 주소가 있는 사용자를 생각해 보세요:
{
"id": 42,
"username": "jdoe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62704",
"country": "US"
}
}
중첩 객체를 자체 인터페이스로 분리합니다:
interface Address {
street: string;
city: string;
zipCode: string;
country: string;
}
interface User {
id: number;
username: string;
address: Address;
}
이렇게 하면 각 타입이 집중적이고 재사용 가능해집니다. 다른 엔드포인트도 Address를 반환한다면 같은 인터페이스를 사용합니다.
필드에 배열이 포함된 경우 요소 타입을 주석으로 달아줍니다:
{
"id": 42,
"username": "jdoe",
"roles": ["admin", "editor"],
"posts": [
{ "id": 1, "title": "Hello World" },
{ "id": 2, "title": "TypeScript Tips" }
]
}
interface Post {
id: number;
title: string;
}
interface User {
id: number;
username: string;
roles: string[];
posts: Post[];
}
간단한 경우에는 Type[] 구문을 사용합니다. 복잡한 제네릭의 경우 Array<Type>이 더 명확할 수 있지만, 어느 것을 선택하든 일관성이 더 중요합니다.
API는 종종 있을 수도 있고 없을 수도 있는 필드를 반환합니다. ?로 표시합니다:
{
"id": 42,
"username": "jdoe",
"bio": "Software developer"
}
interface User {
id: number;
username: string;
bio?: string; // 응답에서 빠질 수 있음
}
선택적 필드(bio?: string)는 키 자체가 완전히 없을 수 있다는 의미입니다. 이것은 키는 존재하지만 값이 null일 수 있는 nullable 필드와 다릅니다.
프로덕션 API는 지저분합니다. 일반적인 문제들을 처리하는 방법은 다음과 같습니다.
필드가 명시적으로 null일 수 있는 경우:
{
"id": 42,
"username": "jdoe",
"avatarUrl": null,
"deletedAt": null
}
interface User {
id: number;
username: string;
avatarUrl: string | null;
deletedAt: string | null; // ISO 날짜 문자열 또는 null
}
필드가 부재와 null 모두 가능한 경우 둘을 결합합니다:
interface User {
id: number;
username: string;
middleName?: string | null; // 없을 수도 있고, 있지만 null일 수도 있음
}
일부 필드는 여러 타입을 허용합니다. 검색 API가 혼합된 결과를 반환할 수 있습니다:
{
"results": [
{ "type": "user", "id": 1, "username": "jdoe" },
{ "type": "post", "id": 2, "title": "Hello" }
]
}
판별 유니온으로 모델링합니다:
interface UserResult {
type: 'user';
id: number;
username: string;
}
interface PostResult {
type: 'post';
id: number;
title: string;
}
type SearchResult = UserResult | PostResult;
interface SearchResponse {
results: SearchResult[];
}
type 필드가 판별자 역할을 합니다. TypeScript는 이를 확인할 때 자동으로 타입을 좁힙니다:
function renderResult(result: SearchResult) {
if (result.type === 'user') {
console.log(result.username); // TypeScript가 UserResult임을 알고 있음
} else {
console.log(result.title); // TypeScript가 PostResult임을 알고 있음
}
}
API가 고정된 문자열 값 집합을 반환할 때, TypeScript enum 대신 문자열 리터럴 유니온을 사용합니다:
{ "status": "active" }
type UserStatus = 'active' | 'inactive' | 'suspended';
interface User {
id: number;
status: UserStatus;
}
문자열 리터럴 유니온은 더 단순하고, 트리 셰이킹이 가능하며, enum처럼 런타임 JavaScript를 생성하지 않습니다. 역방향 매핑(숫자에서 이름으로)이 필요한 경우에만 enum을 사용하세요.
interface와 type 모두 객체 형태를 설명할 수 있습니다. 각각을 언제 선택할지 알아보겠습니다:
확장할 수 있는 객체 형태에는 interface를 사용합니다:
interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
interface User extends BaseEntity {
username: string;
email: string;
}
유니온, 인터섹션, 계산된 타입에는 type을 사용합니다:
type ApiResponse<T> = {
data: T;
meta: { total: number; page: number };
};
type UserOrAdmin = User | Admin;
실제로는 대부분의 경우 어느 것이든 작동합니다. 프로젝트에 하나의 규칙을 정하고 지키세요. 많은 팀이 데이터 모델에는 interface를, 그 외 모든 것에는 type을 사용합니다.
최대 안전성을 위해 Zod로 형태를 정의하고 TypeScript 타입을 추론합니다:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
bio: z.string().nullable().optional(),
});
type User = z.infer<typeof UserSchema>;
// 이제 런타임에서 유효성 검사
const response = await fetch('/api/users/42');
const data = await response.json();
const user = UserSchema.parse(data); // 형태가 일치하지 않으면 에러 발생
Zod를 사용하면 타입과 유효성 검사 로직이 한 곳에 있습니다. API가 형태를 변경하면, parse()가 잘못된 데이터가 앱 전체에 전파되도록 두지 않고 즉시 에러를 던집니다.
타입은 데이터가 애플리케이션에 들어오는 경계에서 적용할 때만 유용합니다.
타입을 강제하는 제네릭 fetch 함수를 만듭니다:
async function fetchJson<T>(url: string, schema?: z.ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (schema) {
return schema.parse(data);
}
return data as T;
}
// 사용법
const user = await fetchJson<User>('/api/users/42');
const validatedUser = await fetchJson('/api/users/42', UserSchema);
Axios를 사용한다면 응답 제네릭에 타입을 적용합니다:
import axios from 'axios';
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
lastPage: number;
};
}
async function getUsers(page: number = 1) {
const response = await axios.get<PaginatedResponse<User>>('/api/users', {
params: { page },
});
return response.data; // 완전히 타입이 적용됨
}
React 앱에서 데이터 페칭 라이브러리와 타입을 결합합니다:
import { useQuery } from '@tanstack/react-query';
function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchJson<User>(`/api/users/${id}`),
});
}
// 컴포넌트에서
const { data: user, isLoading } = useUser(42);
// user는 User | undefined로 타이핑됨
tsconfig.json에서 strict 모드를 활성화합니다(최소한 strictNullChecks):
{
"compilerOptions": {
"strict": true
}
}
엄격한 null 검사 없이는 TypeScript가 잠재적으로 null인 값에서 .username에 접근하는 것을 불평 없이 허용합니다. 이는 API 응답 타이핑의 목적을 무효화합니다.
readonly 사용API 응답은 받은 데이터이지, 변경해야 할 데이터가 아닙니다. 속성을 readonly로 표시합니다:
interface User {
readonly id: number;
readonly username: string;
readonly email: string;
readonly createdAt: string;
}
또는 전체 인터페이스에 유틸리티 타입을 사용합니다:
type ImmutableUser = Readonly<User>;
이는 미묘한 버그를 유발하는 실수로 인한 변경을 방지합니다.
브랜드 타입으로 다른 엔티티의 ID 혼동을 방지합니다:
type UserId = number & { readonly __brand: 'UserId' };
type PostId = number & { readonly __brand: 'PostId' };
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = 42 as UserId;
const postId = 7 as PostId;
getUser(userId); // OK
getUser(postId); // 타입 오류! UserId가 필요한 곳에 PostId를 전달할 수 없음
모든 API 관련 타입을 전용 디렉토리에 보관합니다:
src/
types/
api/
user.ts
post.ts
common.ts // PaginatedResponse 같은 공유 타입
index.ts // 재내보내기
이렇게 하면 타입을 쉽게 찾고, 가져오고, 유지 관리할 수 있습니다. API가 변경되면 하나의 파일을 업데이트하고 컴파일러가 깨지는 모든 것을 보여줍니다.
외부 데이터를 절대 신뢰하지 마세요. 완벽한 타입이 있더라도 API가 예상치 못한 것을 반환할 수 있습니다. 응답이 앱에 들어오는 곳에서 유효성을 검사합니다:
// 이렇게 하지 마세요
const user = (await response.json()) as User; // 맹목적으로 신뢰
// 이렇게 하세요
const data = await response.json();
const user = UserSchema.parse(data); // 형태를 유효성 검사
타입 단언(as User)은 컴파일러를 침묵시키지만 데이터를 확인하지 않습니다. 런타임 유효성 검사(Zod, io-ts, Valibot)는 실제로 형태가 일치하는지 확인합니다.
크거나 복잡한 JSON 페이로드를 다룰 때, 타입을 작성하기 전에 JSON 에디터를 사용하여 구조를 탐색하고 이해하세요. 원시 텍스트보다 적절한 에디터에서 중첩 관계를 보는 것이 훨씬 쉽습니다.
JSON API로 작업하는 팀을 위한 관련 가이드:
API에 OpenAPI(Swagger) 스펙이 있다면, JSON 변환을 완전히 건너뛰세요. openapi-typescript 같은 도구가 스펙에서 직접 타입을 생성합니다:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.ts
이 접근 방식이 우수한 이유는 스펙이 선택적 필드, nullable 타입, enum을 명시적으로 정의하기 때문입니다. 샘플 데이터에서 추측하지 않고 정확한 타입을 얻습니다.
처리는 브라우저에서 실행됩니다. 저장과 공유는 선택 사항이며 계정이 필요하고 선택한 콘텐츠를 서비스로 전송합니다.
네. 변환기는 각 중첩 객체에 대해 별도의 인터페이스를 생성하고 올바르게 참조합니다.
생성된 TypeScript 타입은 프로덕션 준비가 완료되었습니다. 복사하거나 .ts 파일로 다운로드하세요.
네. 변환기는 배열 타입을 감지하고 올바른 TypeScript 배열 구문(Type[])을 생성합니다. 값이 null이거나 undefined일 수 있을 때 선택적 필드가 감지됩니다.