JSON to TypeScript 변환, ?와 | 읽는 법

API 응답으로 받은 JSON 샘플을 보면서 TypeScript interface를 손으로 타이핑하다 보면, 어떤 키에 물음표(?)를 붙여야 하는지 판단이 애매해지는 순간이 옵니다.
이 물음표와 유니온(|) 표시는 감이 아니라 정해진 규칙으로 계산되는 값이고, 규칙을 알면 자동 생성된 코드를 눈으로 검증할 수 있습니다.
이 글은 그 규칙과, 중첩 객체가 왜 하나의 큰 interface가 아니라 여러 개로 쪼개지는지를 실제 JSON으로 짚습니다.
요약 ① 배열 요소들 중 일부에만 있는 키에는 optional(
?)이 붙고, 모든 요소에 있으면 붙지 않습니다. ② 같은 키라도 요소마다 값의 종류(타입)가 다르면 유니온(|)으로 합쳐집니다. ③ 중첩 객체와 배열 요소 객체는 키 이름 기반 이름이 붙어 별도의 interface로 분리됩니다.
배열 안 JSON을 손으로 타이핑할 때 생기는 문제
API 문서 대신 실제 응답 JSON 샘플 하나만 받아서 타입을 만들어야 하는 경우가 흔합니다. 주문 목록 API 응답이라면 items 배열 안에 상품마다 다른 항목이 들어 있을 수 있습니다.
어떤 상품에는 할인율(discountRate)이 있고 어떤 상품에는 없는 식입니다.
손으로 적을 때 흔히 생기는 판단 실수
이때 손으로 interface를 적으면 흔히 두 가지 실수가 생깁니다.
모든 필드를 다 optional(?)로 적어버려서 타입 안정성이 사라지거나, 반대로 실제로는 일부 요소에만 있는 필드를 필수로 적어서 다른 응답 샘플을 넣었을 때 컴파일 에러가 납니다.
중첩된 고객 정보(customer)나 배열 요소(items)를 어디까지 하나의 interface에 몰아넣고 어디부터 따로 뺄지도 손으로 판단하기 애매합니다.
이 세 가지 — optional 여부, 유니온 여부, 분리 여부 — 는 실제로는 JSON 구조만 보면 기계적으로 결정되는 값입니다.
JSON 샘플만 보고 직접 타입을 만드는 방법
도구 없이도 아래 순서를 그대로 따라가면 같은 결과를 손으로 만들 수 있습니다.
- 최상위 객체의 키 목록을 적습니다.
- 각 키의 값이 원시값(문자열·숫자·불리언·null)인지, 객체인지, 배열인지 구분합니다.
- 원시값 키는 그 타입을 그대로 적습니다.
- 객체 값은 키 이름을 파스칼케이스로 바꾼 이름을 붙여 별도 interface로 뺍니다.
- 배열 값은 그 안의 요소 전부를 하나의 비교 대상(표본 집합)으로 놓고, 요소마다 각 키가 있는지·값 종류가 무엇인지 비교합니다.
- 표본 중 일부에만 있는 키는 물음표(
?)를 붙이고, 존재할 때의 값 종류가 여러 가지면 그 종류를|로 이어 적습니다.
실제 JSON으로 해보겠습니다.
{
"orderId": 1024,
"customer": { "name": "김민준", "email": "minjun@example.com" },
"couponCode": "WELCOME10",
"items": [
{ "productId": "A1", "price": 12000, "discountRate": 0.1 },
{ "productId": "A2", "price": 8000, "discountRate": null },
{ "productId": "A3", "price": 15000 }
]
}
orderId는 숫자 하나뿐이므로 number, couponCode는 문자열 하나뿐이므로 string입니다. customer는 객체이므로 이름을 붙여 뺍니다.
키 이름 customer를 파스칼케이스로 바꾸면 Customer이고, 안의 name·email은 모두 문자열이라 { name: string; email: string }입니다.
items 배열 요소를 표본으로 비교하기
items는 배열이므로 요소 3개를 표본으로 놓고 비교합니다. productId와 price는 요소 3개 모두에 있고 값 종류도 각각 문자열·숫자 하나뿐이라 그대로 string, number입니다.
discountRate는 요소1에 숫자, 요소2에 null, 요소3에는 키 자체가 없습니다.
존재 여부가 갈리니 물음표가 붙고, 존재할 때의 값 종류가 숫자와 null 두 가지이니 유니온으로 합쳐 discountRate?: number | null이 됩니다.
배열 요소 객체에도 이름이 필요합니다. items를 단수형으로 바꾼 item에 이름을 붙이면 Item입니다. 여기까지 모으면 손으로 만든 결과는 이렇습니다.
interface Customer {
name: string;
email: string;
}
interface Item {
productId: string;
price: number;
discountRate?: number | null;
}
interface Root {
orderId: number;
customer: Customer;
couponCode: string;
items: Item[];
}
배열 요소가 3개뿐인 작은 예시라 손으로도 비교가 가능했지만, 실제 API 응답은 요소가 수십·수백 개이고 키도 훨씬 많습니다. 그만큼 눈으로 하나씩 비교하다 놓치는 키가 늘어납니다.
저희 도구로 확인하기.
브라우저 안에서 즉시 변환되며 입력한 JSON은 서버로 전송되거나 저장되지 않습니다

결과 코드에서 자주 헷갈리는 표시들
| 상황 | 결과 표시 | 이유 |
|---|---|---|
| 배열 요소 일부에만 키가 있음 | key?: T | 표본 중 일부에만 존재 → optional |
| 같은 키인데 값 종류가 다름 | key: A | B | 표본마다 값 타입이 다름 → 유니온 |
| 일부에만 있고 값 종류도 다름 | key?: A | B | 존재 여부와 값 종류가 동시에 갈림 |
| 서로 다른 두 객체의 키·타입 구조가 완전히 같음 | 같은 interface를 재사용 | 동일한 구조는 하나의 이름으로 합쳐짐 |
| 중첩 객체 / 배열 요소 객체 | 별도 이름 붙은 interface로 분리 | 키 이름 기반 이름(파스칼케이스)이 붙음 |
한 가지 주의할 점이 있습니다. 물음표와 유니온은 비교할 표본이 여러 개일 때만 계산됩니다. JSON에 배열이 하나도 없는 단일 객체만 붙여넣으면 비교 대상이 하나뿐이라 물음표도 유니온도 나타나지 않습니다.
여러 응답 사례를 함께 확인하려면 최상위 JSON 자체를 배열로 감싸 여러 건을 한 번에 넣으면 됩니다.
최상위 타입 이름은 입력창(기본값 Root)에서 바꿀 수 있고, interface로 선언할지 type으로 선언할지, export 키워드를 붙일지(기본 켜짐)도 옵션으로 고를 수 있습니다.
어떤 선언 방식을 고르든 위에서 설명한 optional·유니온·분리 규칙은 동일하게 적용됩니다.
정리
- optional(
?)은 "표본 중 일부에만 그 키가 있는지"로 결정됩니다. - 유니온(
|)은 "그 키의 값 종류가 표본마다 다른지"로 결정됩니다. - 두 조건이 겹치면
key?: A | B처럼 함께 표시됩니다. - 중첩 객체와 배열 요소 객체는 키 이름 기반 이름으로 별도 interface가 되고, 구조가 같으면 재사용됩니다.
- 배열이 없는 단일 객체만 넣으면 비교 대상이 하나뿐이라 물음표·유니온이 나타나지 않습니다.
브라우저 안에서 즉시 변환되며 입력한 JSON은 서버로 전송되거나 저장되지 않습니다
자주 묻는 질문
optional(?)이 붙는 기준이 정확히 뭔가요?
배열 요소들(표본) 중 일부에만 그 키가 존재할 때 붙습니다. 모든 요소에 키가 있으면 값이 null이어도 optional은 붙지 않고, 대신 값 종류에 null이 섞여 있다는 사실은 유니온으로 표시됩니다.
유니온(|)은 언제 생기나요?
같은 키에 대해 표본마다 값의 타입(문자열·숫자·불리언·null·객체 등)이 다를 때 그 종류를 |로 이어 붙여 표시합니다. 종류가 하나뿐이면 유니온 없이 단일 타입만 나옵니다.
중첩 객체는 왜 하나의 interface에 다 들어가지 않고 나뉘나요?
객체 값이나 배열 요소 객체는 키 이름을 파스칼케이스로 바꾼 이름이 붙어 별도의 interface(또는 type)로 분리됩니다.
서로 다른 두 위치의 객체가 키·타입 구조까지 완전히 같으면 새로 만들지 않고 같은 interface를 재사용합니다.
입력한 JSON이 서버에 저장되나요?
아니요. JSON 파싱과 타입 추론은 모두 브라우저 안에서 입력할 때마다 즉시 실행되며, 입력 내용은 서버로 전송되거나 저장되지 않습니다.
