TypeScript JSON Types: unknown, satisfies, Zod & Record
Last updated:
TypeScript has no built-in JSON type — JSON.parse() returns any by default, but you can type it as unknown and use type guards to safely narrow the type without runtime errors. TypeScript 4.9 introduced the satisfies operator — const config = { ... } satisfies Config validates the object against Config at compile time while preserving the literal type. Zod's z.infer<typeof schema> generates a TypeScript type from a Zod schema in one line, eliminating interface drift between your runtime validator and your static type.
This guide covers unknown vs any for JSON.parse(), type guard patterns, the satisfies operator, Zod schema inference, Record types for dynamic keys, discriminated unions for API responses, and utility types for transforming JSON shapes. All patterns compile with TypeScript strict mode.
JSON.parse() and unknown vs any in TypeScript
The root problem with TypeScript JSON typing is that JSON.parse() is declared in the standard library as returning any. That means TypeScript will not complain about any property access or method call on the parsed result — bugs that would crash at runtime pass the type checker silently. Annotating the result as unknown forces TypeScript to demand narrowing before use, catching those bugs at compile time.
// ── The any problem: silent type errors ──────────────────────────
const raw = '{"user":{"name":"Alice"},"score":42}';
// JSON.parse returns any — no type errors, but runtime crashes are hidden
const data = JSON.parse(raw);
console.log(data.user.name.toUpperCase()); // works
console.log(data.user.email.trim()); // COMPILES — crashes at runtime!
console.log(data.nonexistent.deeply); // COMPILES — crashes at runtime!
// ── The unknown solution: compile-time safety ─────────────────────
const safeData: unknown = JSON.parse(raw);
// TypeScript now requires narrowing before access
// safeData.user.name; // Error: Object is of type 'unknown'
// Option 1: type assertion (fast, zero runtime safety)
const typed = safeData as { user: { name: string }; score: number };
console.log(typed.user.name.toUpperCase()); // TypeScript trusts you
// Option 2: type guard (safe, compile-time + runtime)
function isUserPayload(val: unknown): val is { user: { name: string }; score: number } {
return (
typeof val === "object" &&
val !== null &&
"user" in val &&
typeof (val as Record<string, unknown>).user === "object"
);
}
if (isUserPayload(safeData)) {
console.log(safeData.user.name); // fully typed, no assertion needed
}
// Option 3: Zod (safest — runtime + compile-time, no duplication)
import { z } from "zod";
const PayloadSchema = z.object({
user: z.object({ name: z.string() }),
score: z.number(),
});
type Payload = z.infer<typeof PayloadSchema>; // { user: { name: string }; score: number }
const parsed: Payload = PayloadSchema.parse(JSON.parse(raw));
// throws ZodError if shape is wrong — caught at the boundary
// ── any vs unknown side-by-side ───────────────────────────────────
let a: any = JSON.parse('42');
let u: unknown = JSON.parse('42');
a.foo.bar; // compiles — silently wrong
// u.foo.bar; // Error: Object is of type 'unknown'
a + 1; // compiles
// u + 1; // Error: Operator '+' cannot be applied to 'unknown'
(a as string).toUpperCase(); // compiles
(u as string).toUpperCase(); // compiles — assertion still allowed
typeof u === "string" && u.toUpperCase(); // narrowing — cleanThe practical rule: use unknown everywhere you would reach for any for parsed JSON. The one legitimate use of any is gradual migration — if you have a large codebase with untyped JSON parsing and you want to add types incrementally, any lets existing code keep compiling while you migrate. In new code and in TypeScript strict mode, unknown with Zod is the pattern. See also the JSON.parse() guide for JavaScript-side parsing details and the JSON data types reference.
Type Guards for Safe JSON Narrowing
A type guard is a function that returns a type predicate (val is T) and performs runtime checks to verify the shape of an unknown value. When the type guard returns true, TypeScript narrows the type to T inside the if block. Type guards are the low-dependency alternative to Zod — no external library required, fully composable, and compatible with all TypeScript versions.
// ── Primitive type guards ─────────────────────────────────────────
function isString(val: unknown): val is string {
return typeof val === "string";
}
function isNumber(val: unknown): val is number {
return typeof val === "number" && !isNaN(val);
}
// ── Object shape guard — checks required keys and types ───────────
interface ApiUser {
id: number;
name: string;
email: string;
role: "admin" | "user" | "guest";
}
function isApiUser(val: unknown): val is ApiUser {
if (typeof val !== "object" || val === null) return false;
const obj = val as Record<string, unknown>;
return (
typeof obj.id === "number" &&
typeof obj.name === "string" &&
typeof obj.email === "string" &&
(obj.role === "admin" || obj.role === "user" || obj.role === "guest")
);
}
const raw = JSON.parse('{"id":1,"name":"Alice","email":"alice@example.com","role":"admin"}');
const data: unknown = raw;
if (isApiUser(data)) {
console.log(data.id); // number — fully typed
console.log(data.role); // "admin" | "user" | "guest"
}
// ── Array type guard ──────────────────────────────────────────────
function isArrayOf<T>(val: unknown, guard: (item: unknown) => item is T): val is T[] {
return Array.isArray(val) && val.every(guard);
}
const rawList: unknown = JSON.parse('[{"id":1,"name":"Alice","email":"a@b.com","role":"admin"}]');
if (isArrayOf(rawList, isApiUser)) {
rawList.forEach((user) => console.log(user.name)); // typed as ApiUser[]
}
// ── Nullable / optional field guard ──────────────────────────────
interface Profile {
username: string;
bio: string | null; // nullable in JSON
age?: number; // optional field
}
function isProfile(val: unknown): val is Profile {
if (typeof val !== "object" || val === null) return false;
const obj = val as Record<string, unknown>;
return (
typeof obj.username === "string" &&
(obj.bio === null || typeof obj.bio === "string") &&
(obj.age === undefined || typeof obj.age === "number")
);
}
// ── Composable: build complex guards from simple ones ─────────────
interface ApiResponse {
status: "ok" | "fail";
payload: ApiUser;
}
function isApiResponse(val: unknown): val is ApiResponse {
if (typeof val !== "object" || val === null) return false;
const obj = val as Record<string, unknown>;
return (
(obj.status === "ok" || obj.status === "fail") &&
isApiUser(obj.payload) // compose existing guard
);
}
// ── assertShape: throw on mismatch (like Zod.parse) ───────────────
function assertShape<T>(val: unknown, guard: (v: unknown) => v is T, label = "value"): T {
if (!guard(val)) throw new TypeError(`Invalid shape for ${label}`);
return val;
}
const user = assertShape(JSON.parse(raw), isApiUser, "user");
// user is ApiUser — throws TypeError if shape is wrongType guards shine for small, stable shapes in performance-critical hot paths where the overhead of a full schema library matters. The trade-off is verbosity — a Zod schema is 3 lines where the equivalent type guard is 15. For schemas that change often (API response shapes during development), Zod wins because the type and the validator are one artifact. For fixed internal shapes like configuration objects or well-known data structures, hand-written type guards remain useful. See the safe JSON parsing in TypeScript guide for a full treatment of safe-parse patterns.
The satisfies Operator for JSON Configuration
The satisfies operator (TypeScript 4.9+) solves the tension between type safety and type preservation. A plain type annotation widens the inferred type to the annotation — you lose literal types and autocomplete on specific values. satisfies validates the shape at compile time while preserving the literal types in the inferred type, giving you both type-checking and specific autocomplete.
// ── The widening problem with plain annotation ────────────────────
type Theme = "light" | "dark" | "system";
interface AppConfig {
theme: Theme;
language: string;
features: Record<string, boolean>;
}
// Plain annotation — theme widens to Theme, not "dark"
const config1: AppConfig = {
theme: "dark",
language: "en",
features: { analytics: true, beta: false },
};
// config1.theme is typed as Theme — you lose the literal "dark"
// config1.features.analytics is unknown — Record<string, boolean>
// ── satisfies — validates shape, preserves literal types ──────────
const config2 = {
theme: "dark",
language: "en",
features: { analytics: true, beta: false },
} satisfies AppConfig;
// config2.theme is typed as "dark" — literal preserved!
// config2.features.analytics is typed as true — preserved!
// config2.features.missing // Error: no index signature — only known keys
// ── satisfies catches shape errors at the satisfies site ──────────
const badConfig = {
theme: "purple", // Error: Type '"purple"' is not assignable to type 'Theme'
language: "en",
features: {},
} satisfies AppConfig;
// ── satisfies for JSON API response validation ────────────────────
interface SuccessResponse {
status: "success";
data: { id: number; name: string };
}
// Validate a hardcoded test fixture against the interface
const fixture = {
status: "success",
data: { id: 1, name: "Alice" },
} satisfies SuccessResponse;
// fixture.status is "success" (literal), not "success" | "error"
// Useful in tests and mock data — TypeScript validates the shape
// without widening the response type
// ── satisfies with const — maximum literal preservation ──────────
const routes = {
home: "/",
about: "/about",
docs: "/guides",
} satisfies Record<string, string>;
// routes.home is "/" (literal), not string
// Useful for mapping objects where you want key autocomplete
// and literal value types simultaneously
// ── Combined: satisfies + as const for readonly literals ──────────
const STATUS_MAP = {
200: "ok",
404: "not_found",
500: "server_error",
} as const satisfies Record<number, string>;
// STATUS_MAP[200] is "ok" (literal, readonly)
// TypeScript also validates that all values are stringsThe most practical use of satisfies for JSON is typed configuration objects and test fixtures. When you define a mock API response in a test, satisfies validates the shape against the interface without losing the specific literal values — making the mock both type-safe and usable in discriminated union narrowing downstream. Note that satisfies is compile-time only — it does not add runtime validation. For runtime JSON from external sources, combine satisfies with a Zod schema or a type guard.
Zod: Schema-First TypeScript Types for JSON
Zod is the most widely adopted TypeScript-first schema library — it defines the shape once as a Zod schema, derives the TypeScript type from it with z.infer<typeof schema>, and validates JSON at runtime in one step. There is no interface to maintain separately from the validator, eliminating the drift that accumulates when you update the interface but forget to update the validation function.
import { z } from "zod";
// ── Define schema once, derive type and validator ─────────────────
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["admin", "user", "guest"]),
bio: z.string().nullable(), // string | null
age: z.number().optional(), // number | undefined
tags: z.array(z.string()).default([]), // string[], default []
});
type User = z.infer<typeof UserSchema>;
// {
// id: number;
// name: string;
// email: string;
// role: "admin" | "user" | "guest";
// bio: string | null;
// age?: number;
// tags: string[];
// }
// ── Parse — throws ZodError on mismatch ──────────────────────────
const raw = '{"id":1,"name":"Alice","email":"alice@example.com","role":"admin","bio":null}';
const user: User = UserSchema.parse(JSON.parse(raw));
// user.tags is [] (default applied)
// ── safeParse — returns success/error result ──────────────────────
const result = UserSchema.safeParse(JSON.parse(raw));
if (result.success) {
const u = result.data; // typed as User
console.log(u.name);
} else {
result.error.issues.forEach(issue =>
console.error(`${issue.path.join(".")}: ${issue.message}`)
);
}
// ── Nested objects and arrays ─────────────────────────────────────
const PostSchema = z.object({
id: z.number(),
title: z.string(),
author: UserSchema, // nested — reuse schemas
tags: z.array(z.string()),
metadata: z.record(z.string(), z.unknown()), // Record<string, unknown>
});
type Post = z.infer<typeof PostSchema>;
// ── Transform: parse and transform in one step ────────────────────
const DateSchema = z.string().transform((s) => new Date(s));
// z.infer gives Date, not string — the output type of the transform
const EventSchema = z.object({
name: z.string(),
startDate: DateSchema, // parses ISO string, returns Date
});
type Event = z.infer<typeof EventSchema>;
// { name: string; startDate: Date }
// ── Discriminated union ──────────────────────────────────────────
const ApiResponseSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("success"), data: UserSchema }),
z.object({ type: z.literal("error"), code: z.number(), message: z.string() }),
]);
type ApiResponse = z.infer<typeof ApiResponseSchema>;
const response = ApiResponseSchema.parse(JSON.parse(raw));
if (response.type === "success") {
console.log(response.data.name); // TypeScript narrows to success branch
}
// ── Partial and Pick for JSON subsets ─────────────────────────────
const PartialUserSchema = UserSchema.partial(); // all fields optional
const UserPreviewSchema = UserSchema.pick({ id: true, name: true }); // subset
type UserPreview = z.infer<typeof UserPreviewSchema>; // { id: number; name: string }Zod's transform() is particularly powerful for JSON — it lets you parse ISO date strings into Date objects, convert snake_case keys to camelCase, or apply computed defaults, all within the schema. The derived TypeScript type reflects the output of the transform, not the raw JSON shape. For API response types that you own and control, define the Zod schema in a shared module imported by both the server and client — a single source of truth for the contract. See the JSON Schema validation guide for comparisons with JSON Schema / AJV.
Record Types and Index Signatures for Dynamic JSON
When JSON keys are not known at compile time — user-configurable settings, dynamic metadata, arbitrary string maps — TypeScript's Record type and index signatures provide the right abstraction. Record<string, unknown> is the safest starting point for arbitrary JSON objects; narrow to more specific value types when you know them.
// ── Record<K, V>: typed map with dynamic string keys ─────────────
// Record<string, unknown> — safest for arbitrary JSON objects
const metadata: Record<string, unknown> = JSON.parse('{"foo":1,"bar":"baz"}');
// Narrow values before use
Object.entries(metadata).forEach(([key, value]) => {
if (typeof value === "string") console.log(key, value.toUpperCase());
if (typeof value === "number") console.log(key, value * 2);
});
// Record<string, number> — word-count dictionary, score maps
const scores: Record<string, number> = {
alice: 95, bob: 87, carol: 92,
};
// TypeScript infers scores["alice"] as number (not number | undefined)
// Use Record<string, number | undefined> if keys may be absent
// Record<string, string> — i18n translation map, header map
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Request-Id": "abc-123",
};
// ── Index signatures: interface syntax alternative ────────────────
interface TranslationMap {
[locale: string]: string;
}
const translations: TranslationMap = {
en: "Hello", fr: "Bonjour", de: "Hallo",
};
// ── Mixed: known keys + dynamic additional keys ───────────────────
interface Config {
name: string;
version: number;
[key: string]: unknown; // additional dynamic keys — must be supertype
}
const config: Config = {
name: "my-app",
version: 2,
debug: true, // extra key allowed
timeout: 5000, // extra key allowed
};
// ── Record with union value type ──────────────────────────────────
type Permission = "read" | "write" | "admin";
interface RoleMap {
[role: string]: Permission[];
}
const rbac: RoleMap = {
editor: ["read", "write"],
moderator: ["read", "write", "admin"],
viewer: ["read"],
};
// ── Zod: runtime Record validation ───────────────────────────────
import { z } from "zod";
const ScoreMapSchema = z.record(z.string(), z.number().min(0).max(100));
type ScoreMap = z.infer<typeof ScoreMapSchema>; // Record<string, number>
const scores2 = ScoreMapSchema.parse(JSON.parse('{"alice":95,"bob":87}'));
// ── Discriminated record: typed object with known keys ────────────
// When you know some keys but the rest are dynamic:
const FeatureFlagsSchema = z.object({
rolloutPercentage: z.number(),
enabledForRoles: z.array(z.string()),
}).catchall(z.boolean()); // extra keys must be boolean
type FeatureFlags = z.infer<typeof FeatureFlagsSchema>;
// { rolloutPercentage: number; enabledForRoles: string[]; [k: string]: boolean | number | string[] }
// ── keyof Record pattern: typed key access ────────────────────────
type Locale = "en" | "fr" | "de";
type Translations = Record<Locale, string>;
const greetings: Translations = { en: "Hello", fr: "Bonjour", de: "Hallo" };
function getGreeting(locale: Locale): string {
return greetings[locale]; // typed — string, not string | undefined
}A common mistake is using object or { } as the type for a JSON object — both allow any object but neither gives you typed property access. Record<string, unknown> is explicit about the dynamic-key intent and forces narrowing before use, which is exactly what you want. When you know all possible keys at compile time, use a plain interface or Record<KnownKeyUnion, V> where KnownKeyUnion is "en" | "fr" | "de" — TypeScript then exhaustively checks that all keys are present. See also the JSON null handling guide for typing nullable values in Record types.
Discriminated Unions for JSON API Responses
JSON API responses often return one of several distinct shapes depending on whether the request succeeded, what resource type was returned, or what error occurred. Discriminated unions model this cleanly — a union where every member has a literal-typed property that TypeScript uses to narrow the active member in switch/if blocks. This gives you exhaustive type checking with zero runtime overhead.
// ── Basic discriminated union ─────────────────────────────────────
type ApiResult =
| { type: "success"; data: User }
| { type: "error"; code: number; message: string }
| { type: "loading" };
function handleResult(result: ApiResult) {
switch (result.type) {
case "success":
console.log(result.data.name); // User — fully typed
break;
case "error":
console.error(result.code, result.message); // number, string
break;
case "loading":
console.log("Loading...");
break;
default:
// Exhaustive check — TypeScript errors here if a case is missed
const _exhaustive: never = result;
}
}
// ── Discriminated union for paginated API responses ───────────────
interface PaginatedResponse<T> {
type: "paginated";
data: T[];
page: number;
totalPages: number;
}
interface SingleResponse<T> {
type: "single";
data: T;
}
interface ErrorResponse {
type: "error";
code: number;
message: string;
details?: string[];
}
type Response<T> = PaginatedResponse<T> | SingleResponse<T> | ErrorResponse;
function renderUsers(response: Response<User>) {
if (response.type === "paginated") {
response.data.forEach((u) => console.log(u.name)); // User[]
console.log(`Page ${response.page} of ${response.totalPages}`);
} else if (response.type === "single") {
console.log(response.data.name); // User
} else {
console.error(response.message); // string
}
}
// ── Zod discriminated union — efficient parsing ───────────────────
import { z } from "zod";
const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string() });
const ResponseSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("success"), data: UserSchema }),
z.object({ type: z.literal("error"), code: z.number(), message: z.string() }),
]);
type ZodResponse = z.infer<typeof ResponseSchema>;
// { type: "success"; data: { id: number; name: string; email: string } }
// | { type: "error"; code: number; message: string }
// Parse and handle
const raw = '{"type":"success","data":{"id":1,"name":"Alice","email":"a@b.com"}}';
const parsed = ResponseSchema.parse(JSON.parse(raw));
if (parsed.type === "success") {
console.log(parsed.data.name); // TypeScript knows this is the success branch
}
// ── Nested discriminated union: resource types ────────────────────
type Resource =
| { kind: "user"; id: number; username: string }
| { kind: "post"; id: number; title: string; authorId: number }
| { kind: "comment"; id: number; body: string; postId: number };
function getResourceLabel(r: Resource): string {
switch (r.kind) {
case "user": return `@${r.username}`;
case "post": return r.title;
case "comment": return r.body.slice(0, 50);
}
// No default needed — TypeScript knows all cases are covered
}
// ── Type narrowing with in operator ──────────────────────────────
// Alternative to discriminated unions for shapes without explicit kind field
type ShapeA = { radius: number };
type ShapeB = { width: number; height: number };
type Shape = ShapeA | ShapeB;
function area(shape: Shape): number {
if ("radius" in shape) return Math.PI * shape.radius ** 2;
return shape.width * shape.height;
}The discriminant property (type, kind, status) must be a literal type — "success" not string — for TypeScript to use it for narrowing. A common mistake is using string as the discriminant type; TypeScript then cannot narrow the union in switch cases. Zod's z.discriminatedUnion() is more efficient than z.union() because it reads the discriminant key first and skips parsing schemas whose discriminant does not match, which matters for unions with many members.
Utility Types for JSON: Partial, Required, Pick, Omit
TypeScript's built-in utility types let you derive new types from existing JSON interfaces without duplication. Partial<T> makes all fields optional — useful for PATCH request bodies where only changed fields are sent. Required<T> makes all fields required. Pick and Omit extract or exclude specific keys — useful for API response projections and privacy-safe types.
interface User {
id: number;
name: string;
email: string;
password: string; // never send this in responses
createdAt: string;
updatedAt: string;
}
// ── Partial<T>: PATCH request body ────────────────────────────────
type UserPatch = Partial<User>;
// { id?: number; name?: string; email?: string; ... }
async function patchUser(id: number, patch: Partial<Pick<User, "name" | "email">>) {
// Only name and email can be patched — other fields excluded
return fetch(`/api/users/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
// ── Pick<T, K>: projection / response shape ───────────────────────
type PublicUser = Pick<User, "id" | "name" | "createdAt">;
// { id: number; name: string; createdAt: string }
// password and email excluded from public API response
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string }
// ── Omit<T, K>: exclude specific keys ────────────────────────────
type SafeUser = Omit<User, "password">;
// { id: number; name: string; email: string; createdAt: string; updatedAt: string }
type CreateUserInput = Omit<User, "id" | "createdAt" | "updatedAt">;
// { name: string; email: string; password: string }
// Server generates id and timestamps — client does not send them
// ── Required<T>: normalize optional fields ────────────────────────
interface PartialConfig {
theme?: string;
language?: string;
debug?: boolean;
}
const DEFAULTS: Required<PartialConfig> = {
theme: "light", language: "en", debug: false,
};
function mergeConfig(user: PartialConfig): Required<PartialConfig> {
return { ...DEFAULTS, ...user };
}
// ── Readonly<T>: immutable JSON response ─────────────────────────
type ImmutableUser = Readonly<User>;
// All properties are readonly — prevents accidental mutation
// ── ReturnType and Parameters for JSON functions ──────────────────
function parseUser(raw: string): User {
return JSON.parse(raw) as User;
}
type ParsedUser = ReturnType<typeof parseUser>; // User
// ── Combining utility types ───────────────────────────────────────
// PATCH response: safe user shape, all optional, readonly
type PatchResponse = Readonly<Partial<SafeUser>>;
// ── Deep Partial for nested JSON ─────────────────────────────────
// Built-in Partial only goes one level deep
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface Config {
database: { host: string; port: number };
redis: { url: string; ttl: number };
}
type ConfigPatch = DeepPartial<Config>;
// { database?: { host?: string; port?: number }; redis?: { url?: string; ttl?: number } }
// ── Zod equivalents for utility types ────────────────────────────
import { z } from "zod";
const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string() });
const PartialUserSchema = UserSchema.partial(); // Partial<User>
const PickedSchema = UserSchema.pick({ id: true, name: true }); // Pick<User, "id"|"name">
const OmittedSchema = UserSchema.omit({ email: true }); // Omit<User, "email">
// Zod partial validates optionality at runtime — built-in Partial does notThe most impactful utility type pattern for JSON APIs is Omit<User, "password"> for public response types — it prevents accidentally including sensitive fields in serialized responses and makes the safe response type explicit in the codebase. Combine with Zod: define a SafeUserSchema using UserSchema.omit({ password: true }) and use it to serialize responses — the schema enforces that password is not accidentally included at both compile time and runtime. For PATCH operations, Partial<T> is the standard pattern — validate with Zod's .partial() which marks all fields optional in the schema and the derived TypeScript type simultaneously.
Key Terms
- unknown type
- A TypeScript type that represents a value whose type is not yet known. Unlike
any, which disables type checking entirely,unknownrequires you to narrow the type before performing any operations. You can assign anything tounknown, but TypeScript will not let you access properties, call methods, or perform arithmetic on anunknownvalue without first narrowing it viatypeof,instanceof, a type guard, or a type assertion. For JSON parsing, annotatingJSON.parse()results asunknownforces deliberate type narrowing at the parse boundary — moving type errors from runtime to compile time. The TypeScript team recommendsunknownoveranyfor values of uncertain type. - type guard
- A function that returns a type predicate (
val is T) and performs runtime checks to verify the shape of anunknownor union-typed value. When a type guard function returnstrue, TypeScript narrows the type inside the subsequentifblock toT, enabling fully typed access without assertions. For JSON, type guards check that the parsed value has the expected keys and value types:typeof val.name === "string",Array.isArray(val.tags), etc. Type guards are composable — a complex type guard can call simpler ones. The key advantage over type assertions (as T) is that they provide runtime validation in addition to compile-time narrowing; assertions are compile-time only. - satisfies operator
- A TypeScript 4.9+ compile-time operator that validates a value against a type without widening the inferred type to the annotated type. Syntax:
expression satisfies Type. A plain type annotation (const x: Config = { ... }) widens the inferred type ofxtoConfig, losing literal types like"dark". Withsatisfies(const x = { ... } satisfies Config), TypeScript validates the object againstConfigbut preserves the literal types in the inferred type ofx. This is useful for typed configuration objects, test fixtures, and static data where you need both shape validation and specific literal types for downstream consumers.satisfiesadds no runtime behavior. - Zod inference
- The process of extracting a TypeScript type from a Zod schema using
z.infer<typeof schema>. The inferred type matches the output type of the schema — after any transforms,.optional(),.nullable(), and.default()modifiers. For example,z.string().optional()infers asstring | undefined;z.string().transform(s => new Date(s))infers asDate. The key benefit is a single source of truth — the Zod schema is both the TypeScript type definition and the runtime validator. Updating the schema automatically updates the inferred type, eliminating drift between interface declarations and validation code. - Record type
- A TypeScript utility type
Record<K, V>that represents an object where keys are of typeKand values are of typeV. For JSON,Record<string, unknown>types an arbitrary JSON object with dynamic string keys and values of any type — the safest starting point for externally sourced JSON objects.Record<string, number>types a string-keyed map with numeric values.Record<Locale, string>whereLocaleis a union type requires all union members to be present as keys, making it useful for exhaustively typed translation maps.Recordis equivalent to an index signature interface ({ [key: string]: V }) but is more explicit about the dynamic-key intent. - discriminated union
- A TypeScript union type where each member has a literal-typed discriminant property (also called a tag) that TypeScript uses to narrow the active union member in switch/if blocks. Example:
type Result = { type: "ok"; data: T } | { type: "error"; message: string }. The discriminant property (type) must be a string, number, or boolean literal — not a broadstringtype. In aswitch (result.type)statement, TypeScript narrowsresultto the specific member in eachcasebranch. Adding adefault: const _e: never = resultbranch enables exhaustive checking — TypeScript errors at compile time if a union member is unhandled. - index signature
- A TypeScript interface or type syntax for typing objects with dynamic keys:
{ [key: string]: ValueType }. An index signature declares that any string key on the object has the given value type. It is equivalent toRecord<string, ValueType>. When combined with named properties, the index signature value type must be a supertype of all named property value types — if named properties havenumbervalues but the index signature declaresstring, TypeScript reports an error becausenumberis not assignable tostring; useunknownor a union instead. Index signatures allow accessing any key without TypeScript reporting an "element implicitly has an any type" error in strict mode.
FAQ
What does JSON.parse() return in TypeScript?
JSON.parse() returns any in TypeScript — the type definition in the standard library declares it as parse(text: string, reviver?: ...): any. This means TypeScript will not catch any property access or method call on the parsed result at compile time. For example, const data = JSON.parse(text); data.user.email.trim() compiles without errors even if user is missing from the JSON, crashing at runtime. The recommended fix is to annotate the result as unknown: const data: unknown = JSON.parse(text) — TypeScript then requires narrowing before any operation. For maximum safety, pass the result through a Zod schema: const data = MySchema.parse(JSON.parse(text)) — Zod validates the shape at runtime and throws a ZodError with detailed path information on mismatch, and z.infer<typeof MySchema> gives you the TypeScript type.
How do I type JSON.parse() safely in TypeScript?
Three patterns in increasing safety: (1) Type assertion — const data = JSON.parse(text) as MyType. No runtime check — TypeScript trusts you. If the JSON shape differs, you get silent type confusion at runtime. Use only for JSON you fully control and have already validated. (2) unknown + type guard — const data: unknown = JSON.parse(text); if (isMyType(data)) { use data }. Write a type guard function that checks each expected property and returns val is MyType. TypeScript narrows inside the if block. Provides runtime safety without external dependencies. (3) Zod schema — const data = MySchema.parse(JSON.parse(text)). Validates at runtime, throws ZodError with issue paths on failure, and z.infer<typeof MySchema> generates the TypeScript type. This is the recommended pattern for externally sourced JSON in production applications. For non-throwing behavior, use MySchema.safeParse(data) which returns { success: true, data: T } | { success: false, error: ZodError }.
What is the difference between unknown and any for JSON in TypeScript?
any disables the type checker entirely for the annotated value — TypeScript accepts any operation without complaint. unknown tells the type checker "I do not know the type yet" and requires you to prove the type before using it. For JSON parsing, the difference is where errors surface: with any, accessing a missing property compiles cleanly and crashes at runtime; with unknown, the same access is a compile-time error (Object is of type 'unknown'). The rule of thumb: any means "trust me, I know what this is"; unknown means "I do not know yet — make me prove it." TypeScript strict mode does not prevent using any, but the TypeScript team recommends unknown for values of uncertain type. The practical switch: wherever you write const data = JSON.parse(text), change it to const data: unknown = JSON.parse(text) — the compiler then guides you to add the narrowing that was always needed.
How do I use Zod to infer TypeScript types from a JSON schema?
Define a Zod schema, then extract the TypeScript type with z.infer<typeof schema> — no separate interface needed: import { z } from "zod"; const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email() }); type User = z.infer<typeof UserSchema>; — User is { id: number; name: string; email: string }. To parse JSON: const user = UserSchema.parse(JSON.parse(responseText)) — throws ZodError on mismatch with detailed path information. For non-throwing parsing, use UserSchema.safeParse(data) which returns a result object. Optional fields: z.string().optional() infers as string | undefined. Nullable: z.string().nullable() infers as string | null. Arrays: z.array(UserSchema) infers as User[]. Records: z.record(z.string(), z.number()) infers as Record<string, number>. Transforms: z.string().transform(s => new Date(s)) infers as Date — the output type, not the input.
What is the satisfies operator in TypeScript and how does it help with JSON?
satisfies (TypeScript 4.9+) validates a value against a type at compile time without widening the inferred type. A plain annotation like const config: AppConfig = { theme: "dark" } widens config.theme to AppConfig["theme"] (e.g., string), losing the literal "dark". With satisfies: const config = { theme: "dark" } satisfies AppConfig — TypeScript validates the shape against AppConfig but preserves the literal type "dark" for config.theme. For JSON configuration objects, this means you get: (1) compile-time validation that the config matches the required shape, (2) IntelliSense autocomplete on known properties, and (3) specific literal types for downstream consumers that need exact values (e.g., discriminated union narrowing). satisfies is compile-time only — no runtime effect. For actual runtime validation of parsed JSON, combine it with Zod or a type guard.
How do I type a JSON object with dynamic string keys in TypeScript?
Use Record<string, V> where V is the value type. For arbitrary JSON: Record<string, unknown> — safest because it forces narrowing before use. For string maps: Record<string, string>. For typed dictionaries: Record<string, number>. Index signature syntax is equivalent: interface Map { [key: string]: string }. For objects with some known keys plus dynamic extras: interface Config { name: string; [key: string]: unknown } — the index signature value type must be a supertype of all named property types, so use unknown when named properties have different types. Avoid plain object or { } — they accept any object but provide no typed access. For runtime validation, Zod's z.record(z.string(), z.number()) validates a Record<string, number> and throws on any value that is not a number.
How do I model a JSON API response with multiple possible shapes in TypeScript?
Use a discriminated union — a union type where each member has a literal-typed discriminant property: type ApiResponse = { type: "success"; data: User } | { type: "error"; code: number; message: string }. In a switch (response.type) block, TypeScript automatically narrows the response to the correct member — response.data is only accessible in the "success" case. For exhaustive handling, add default: const _e: never = response — TypeScript reports a compile error if a union member is unhandled. With Zod, use z.discriminatedUnion("type", [SuccessSchema, ErrorSchema]) which is faster than z.union() because Zod checks the discriminant first. The discriminant field must be a string literal type ("success"), not a broad string — TypeScript cannot narrow on string discriminants.
How do I make a TypeScript interface from a JSON object?
Three approaches: (1) Manual — inspect the JSON payload and write interface MyType { key: type; ... } by hand. Accurate for small, stable shapes but tedious for large nested structures. Add ? for optional fields and | null for nullable values. (2) Tool-generated — paste the JSON into jsonic.io/json-to-ts or a similar converter; it infers TypeScript interfaces including nested objects, arrays, and optional fields. Review the output — tools may infer number where you want a specific literal, or string where you want a union. (3) Zod schema + z.infer — define the Zod schema matching your JSON structure, then type MyType = z.infer<typeof MySchema>. This is the most robust approach because the schema is both the type source and the runtime validator — no drift. For API responses, treat any inferred interface as provisional until you have seen multiple real payloads — add optional modifiers for fields that may be absent and unknown for fields you do not control.
Further reading and primary sources
- TypeScript Handbook: unknown type — Official TypeScript documentation on type narrowing, type guards, and the unknown type
- TypeScript 4.9 satisfies operator announcement — Official TypeScript 4.9 release notes explaining the satisfies operator with examples
- Zod documentation — Official Zod documentation — schema definition, z.infer, parsing, safeParse, and transforms
- TypeScript Utility Types reference — Official reference for Partial, Required, Pick, Omit, Record, and other built-in utility types
- TypeScript strict mode — TypeScript strict mode options — strictNullChecks, noImplicitAny, and related flags that affect JSON typing