Advanced TypeScript JSON Types: Inference, Generics, and Type Guards
Last updated:
Advanced TypeScript JSON typing goes beyond JSON.parse() returning any — satisfies, const assertions, template literal types, and recursive mapped types give you full compile-time safety for JSON structures. JSON.parse(text) as unknown followed by a Zod parse or manual type guard narrows unknown to your specific type without unsafe casting. const schema = { name: "string", age: 0 } as const with typeof schema derives the shape type from the literal value itself.
This guide covers the satisfies operator for JSON literals, recursive JSON value types, discriminated union parsing, template literal type paths, infer for JSON schema derivation, and ReturnType + Awaited for JSON API responses. Each pattern is shown with complete, runnable code and explained from the bottom up. Internal links connect to TypeScript JSON types for foundational concepts and safe JSON parsing TypeScript for parse-time safety patterns.
The satisfies Operator for JSON Literals
Introduced in TypeScript 4.9, satisfies validates that a value matches a type without widening the inferred type. This is exactly what JSON config objects need: you want TypeScript to check the shape, but you also want to keep the precise literal types for autocomplete and exhaustiveness checking.
Without satisfies, annotating const config: AppConfig = { theme: "dark" } widens theme to string. With satisfies, const config = { theme: "dark" } satisfies AppConfig keeps the type as the literal "dark" while still catching missing or extra fields at compile time. This matters for switch exhaustiveness on JSON enum fields.
type Theme = 'light' | 'dark' | 'system'
type AppConfig = { theme: Theme; locale: string; maxRetries: number }
// Without satisfies — theme is widened to string
const configA: AppConfig = { theme: 'dark', locale: 'en', maxRetries: 3 }
type ThemeA = typeof configA.theme // string (widened)
// With satisfies — theme keeps its literal type
const configB = { theme: 'dark', locale: 'en', maxRetries: 3 } satisfies AppConfig
type ThemeB = typeof configB.theme // 'dark' (preserved!)
// Enum-like JSON map — satisfies validates every key is covered
const LABELS = {
light: 'Light mode',
dark: 'Dark mode',
system: 'System default',
} satisfies Record<Theme, string>
// TypeScript errors if you add a new Theme variant and forget a labelRecursive JSON Value Types
TypeScript has no built-in JSON type. Define a recursive JSONValue type that covers all valid JSON values: primitives, arrays, and objects. TypeScript has supported recursive type aliases since version 3.7.
The base definition handles all cases. From it you can derive JSONObject and JSONArray for narrower variables. DeepPartial<T> is a mapped type variant useful for partial JSON patches — every field becomes optional recursively. One limitation: the type allows circular object references at the type level, but JSON.stringify will throw at runtime if you pass one.
// Recursive JSON value type — covers all valid JSON
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue }
type JSONObject = { [key: string]: JSONValue }
type JSONArray = JSONValue[]
// DeepPartial — recursively optional fields (useful for PATCH payloads)
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
interface User { id: number; name: string; address: { city: string; zip: string } }
type UserPatch = DeepPartial<User>
// { id?: number; name?: string; address?: { city?: string; zip?: string } }
// Usage — any valid JSON value fits JSONValue
function logJson(value: JSONValue) {
console.log(JSON.stringify(value, null, 2))
}
logJson({ users: [{ id: 1, active: true }], count: 1, cursor: null })Type Guards and Narrowing for JSON
A type guard is a function whose return type is a type predicate (value is T). When called in an if condition, TypeScript narrows the variable to T inside the block. This is the safe alternative to as T casts for JSON parsed from unknown.
Write guards that check every field you rely on. Use Array.isArray for arrays and structural checks for objects. Prefer structural checks over instanceof for JSON objects — instanceof checks prototype chains, which plain JSON objects do not use. For discriminated unions, check the discriminant field first to select the right branch.
interface User { id: number; name: string; role: 'admin' | 'viewer' }
// Type predicate guard
function isUser(v: unknown): v is User {
if (typeof v !== 'object' || v === null) return false
const obj = v as Record<string, unknown>
return (
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
(obj.role === 'admin' || obj.role === 'viewer')
)
}
// Usage with JSON.parse
const raw: unknown = JSON.parse(text)
if (isUser(raw)) {
console.log(raw.name) // TypeScript: raw is User here
console.log(raw.role) // 'admin' | 'viewer'
}
// Discriminated union guard — check the tag field first
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number }
function isShape(v: unknown): v is Shape {
if (typeof v !== 'object' || v === null) return false
const obj = v as Record<string, unknown>
if (obj.kind === 'circle') return typeof obj.radius === 'number'
if (obj.kind === 'rect') return typeof obj.width === 'number' && typeof obj.height === 'number'
return false
}Discriminated Unions for JSON APIs
Discriminated unions model JSON API responses where a shared type or status field distinguishes between variants. TypeScript narrows each branch in a switch automatically. This pattern replaces fragile if (response.error) checks with exhaustive, compile-time-verified handling.
Add a default: never branch to make exhaustiveness checking explicit — if a new variant is added to the union but not handled, the never assignment becomes a type error. Use the Exclude utility to compute partial sets of variants. For APIs that mix success and error shapes at the top level, wrap the discriminated union in a JSON data validation layer at the API boundary.
// Discriminated union — 'type' field is the discriminant
type ApiResponse<T> =
| { type: 'success'; data: T; requestId: string }
| { type: 'error'; message: string; code: number }
| { type: 'loading' }
function handleResponse<T>(res: ApiResponse<T>): T | null {
switch (res.type) {
case 'success':
return res.data // TypeScript: res is { type: 'success'; data: T; requestId: string }
case 'error':
console.error(res.message, res.code)
return null
case 'loading':
return null
default:
// Exhaustiveness check — compile error if a new variant is not handled
const _exhaustive: never = res
return _exhaustive
}
}
// Exclude utility — partial union subsets
type NonLoadingResponse<T> = Exclude<ApiResponse<T>, { type: 'loading' }>
// { type: 'success'; data: T; requestId: string } | { type: 'error'; message: string; code: number }Template Literal Types for JSON Paths
Template literal types build string literal types by concatenating other string literal types. For JSON objects, this means you can derive all valid dotted-path strings at the type level, giving you autocomplete and type safety for generic get(obj, path) utilities.
The Path<T> utility recurses over object keys, building "key" and "key.subkey" strings. Pair it with PathValue<T, P> to get the value type at a given path — editors use both to autocomplete path arguments and infer return types. This is the same technique used by libraries like Lodash typings and React Hook Form.
// Template literal path type — recurse over object keys
type Path<T> = T extends object
? {
[K in keyof T]: K extends string
? K | `${K}.${Path<T[K]>}`
: never
}[keyof T]
: never
// Value type at a given path
type PathValue<T, P extends string> =
P extends `${infer K}.${infer Rest}`
? K extends keyof T
? PathValue<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never
interface User {
id: number
name: string
address: { city: string; country: string }
}
type UserPath = Path<User>
// 'id' | 'name' | 'address' | 'address.city' | 'address.country'
type CityType = PathValue<User, 'address.city'> // string
// Generic get utility using both
function get<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P> {
return path.split('.').reduce((acc: unknown, key) => (acc as Record<string, unknown>)[key], obj) as PathValue<T, P>
}
const user: User = { id: 1, name: 'Alice', address: { city: 'NYC', country: 'US' } }
const city = get(user, 'address.city') // typed as string, autocomplete shows all valid pathsInferring Types from Zod Schemas
z.infer<typeof schema> extracts the TypeScript type that a Zod schema validates. Define the schema once — you get both runtime validation and a compile-time type with no duplication. This schema-first approach ensures the two never drift apart.
z.output gives the type after transformations (e.g., after .transform() converts a string to a Date), while z.input gives the type before — useful when the raw JSON shape differs from the processed shape. See JSON Schema patterns for schema-first design patterns.
import { z } from 'zod'
// Schema-first: define once, get type for free
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(200),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
// Transform ISO string to Date — z.input is string, z.output is Date
createdAt: z.string().datetime().transform(s => new Date(s)),
})
// Infer types
type User = z.infer<typeof UserSchema> // output type (Date for createdAt)
type UserInput = z.input<typeof UserSchema> // input type (string for createdAt)
// safeParse — no try/catch needed
const result = UserSchema.safeParse(JSON.parse(text))
if (result.success) {
const user = result.data // typed as User
console.log(user.createdAt) // Date object — transformed!
} else {
result.error.issues.forEach(issue =>
console.error(issue.path.join('.'), issue.message)
)
}
// Compose schemas — reuse across routes
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true })
const UpdateUserSchema = UserSchema.partial().required({ id: true })
type CreateUserInput = z.infer<typeof CreateUserSchema>ReturnType and Awaited for API Responses
ReturnType<typeof fn> extracts the return type of a function. Awaited<T> unwraps Promise<T> to T. Combined, Awaited<ReturnType<typeof fetchUser>> gives you the resolved JSON type of any async fetch function — without duplicating the type annotation.
Build a generic fetch wrapper that accepts a Zod schema and returns validated, typed JSON. The wrapper handles HTTP errors, JSON parsing, and Zod validation in one place. Every call site gets full type inference. An error union at the return type level makes both success and failure cases first-class, eliminating thrown exceptions in favor of return values.
import { z } from 'zod'
// Generic fetch wrapper with Zod validation
async function fetchValidated<T>(
schema: z.ZodType<T>,
url: string,
init?: RequestInit
): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
try {
const res = await fetch(url, {
headers: { Accept: 'application/json', ...init?.headers },
...init,
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` }
const raw = await res.json()
const parsed = schema.safeParse(raw)
if (!parsed.success) return { ok: false, error: parsed.error.issues[0].message }
return { ok: true, data: parsed.data }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : 'Unknown error' }
}
}
// ReturnType + Awaited to extract the type without annotation
async function fetchUser(id: number) {
return fetchValidated(UserSchema, `/api/users/${id}`)
}
type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>
// { ok: true; data: User } | { ok: false; error: string }
// Usage — discriminated union handles both cases
const result = await fetchUser(1)
if (result.ok) {
console.log(result.data.name) // typed as User
} else {
console.error(result.error) // typed as string
}Definitions
- Type guard
- A function with return type
value is Tthat narrows the type of a variable inside anifblock. Enables safe use ofunknownvalues from JSON without unsafe casts. - Discriminated union
- A union type where each member has a shared literal field (the discriminant). TypeScript narrows each branch in a
switchorifautomatically, enabling exhaustive, type-safe handling of JSON API response variants. - Satisfies operator
- TypeScript 4.9+ syntax (
expr satisfies Type) that checks an expression against a type without widening the inferred type. Use for JSON config literals and enum maps to keep precise literal types while still catching shape errors. - Template literal type
- A type that builds a new string literal type by embedding other string literal types inside a template expression:
{"`${Prefix}.${Suffix}`"}. Used to derive all valid dotted JSON paths from an object type. - Recursive type
- A type alias that references itself, enabling types for tree-structured or arbitrarily nested data.
type JSONValue = ... | JSONValue[] | { [key: string]: JSONValue }is the canonical recursive JSON type. - Type predicate
- The return type annotation of a type guard function:
value is T. Tells TypeScript to narrow the variable toTin the truthy branch of the callingifstatement. - Const assertion
- The
as constsuffix on a literal value that makes all propertiesreadonlyand narrows all values to their exact literal types. Combined withtypeof, it lets you derive a type from a JSON literal value.
FAQ
How do I safely type JSON.parse() in TypeScript?
Use JSON.parse(text) as unknown — not as YourType. Then narrow the unknown value with a type guard function (function isUser(v: unknown): v is User) or a Zod schema (UserSchema.parse(raw)). The as T cast pattern is a compile-time lie: TypeScript trusts you without any runtime check, so a mismatched API response will cause silent bugs or runtime crashes rather than a type error.
What is the satisfies operator and when do I use it for JSON?
The satisfies operator (TypeScript 4.9+) checks that a value conforms to a type without widening the inferred type. For JSON config objects: const config = { theme: "dark", locale: "en" } satisfies AppConfig — TypeScript validates the shape and keeps the literal types ("dark" and "en") rather than widening them to string. Use it for JSON literals, enum-like objects, and route maps where you want both shape validation and precise literal inference.
What is a recursive JSON type in TypeScript?
A recursive JSON type is a type alias that references itself, covering all valid JSON values: type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }. This definition is structurally complete — any valid JSON document fits JSONValue. TypeScript supports recursive type aliases since version 3.7. The limitation is that circular object references are allowed at the type level but will throw at JSON.stringify runtime.
How do I create a type guard for a JSON object?
Write a function with return type value is YourType. Check typeof for primitives, Array.isArray for arrays, and nested property access for objects. Call it in an if block — TypeScript narrows the type inside the block. For complex schemas with many fields, Zod generates type guards automatically via schema.safeParse, which is more maintainable than hand-written guards.
What are discriminated unions in TypeScript for JSON APIs?
A discriminated union uses a shared literal field (the discriminant) to distinguish between union members. For JSON APIs: type ApiResponse = { type: "success"; data: User } | { type: "error"; message: string }. Switch on the type field — TypeScript narrows each branch automatically. Add a default: never branch to get a compile error when a new variant is added to the union but not handled in the switch.
How do I use template literal types for JSON paths?
Template literal types concatenate string literal types. For dotted JSON paths, a recursive Path<T> type builds all valid "key" and "key.subkey" strings from an object type. Pair it with PathValue<T, P> to get the value type at a given path. Editors use these for autocomplete in generic get/set utilities, giving you IDE-level safety for string-addressed JSON properties.
How do I infer TypeScript types from a Zod schema?
Use z.infer<typeof MySchema>. Define the schema once: const UserSchema = z.object({ id: z.number(), name: z.string() }). Then type User = z.infer<typeof UserSchema> gives you the TypeScript type for free — no duplication. Use z.output for the type after transformations (e.g., string to Date) and z.input for the type before. This schema-first approach keeps validation and types in sync automatically.
How do I type an async fetch function that returns JSON?
Use Awaited<ReturnType<typeof yourFunction>> to extract the resolved type. For a generic fetch wrapper: async function fetchJson<T>(url: string): Promise<T>. Combine with Zod for runtime safety: return schema.parse(await res.json()) validates the shape before returning. The Awaited utility unwraps nested Promise<Promise<T>> chains that arise from async functions, giving you the final resolved value type.
Further reading and primary sources
- TypeScript 4.9 Release Notes: satisfies operator — Official TypeScript 4.9 release notes covering the satisfies operator with examples for config objects and enum maps
- TypeScript Handbook: Template Literal Types — Official handbook section on template literal types, including recursive path derivation patterns
- Zod Documentation: z.infer — Zod type inference docs: z.infer, z.input, z.output, and schema-first TypeScript patterns
- TypeScript Deep Dive: Type Guards — Comprehensive guide to TypeScript type guards, type predicates, discriminated unions, and narrowing patterns