Recursive Deep Partial: Simplify Nested State Updates in TypeScript Applications
Why One‑Level Partial Falls Short
When I first started using React with a JSON‑heavy API, I relied on TypeScript's built‑in Partial<T> to create flexible update payloads. It worked fine for flat shapes, but our user profiles contain nested objects like address, contacts, and settings. Trying to update just the city inside address forced me to write a type guard or cast away type safety. The result was either verbose code or silent runtime bugs.
I eventually realized we needed a utility that could shallowly partial every level of an object. That’s when I built a recursive DeepPartial type. It lets me write update functions that accept a subset of nested fields, keeping autocomplete and refactoring benefits intact.
Introducing Recursive Deep Partial
The core idea is to walk the shape of T and, for each property, apply Partial again if it’s an object or an array. The implementation is surprisingly concise:
/**
* Recursively makes all properties optional and their values deep partial.
* Useful for creating flexible update payloads for nested objects.
*/
type DeepPartial<T> =
T extends null | undefined ? DeepPartial<T> :
T extends (infer R)[] ? DeepPartialArray<R> :
T extends object ? DeepPartialObject<T> :
T;
type DeepPartialArray<T> = Array<DeepPartial<T>>;
type DeepPartialObject<O> = {
[K in keyof O & string]?: DeepPartial<O[K]>;
};
The conditional types handle three cases:
- null / undefined – we preserve them as optional.
- arrays – we map each element to a deep partial.
- objects – we iterate over each key and make the value deep partial as well.
Why this matters: when you later pass a value of type DeepPartial<UserProfile> to a reducer or an API caller, TypeScript knows exactly which nested fields are allowed. No more any casts, no more runtime errors from missing required fields.
Putting It All Together – A Real‑World Example
Imagine a user profile component that mirrors the API shape:
interface Address {
street: string;
city: string;
zip: string;
}
interface UserProfile {
id: number;
name: string;
email: string;
address?: Address;
preferences?: {
theme: 'light' | 'dark';
notifications: boolean;
};
}
Our component state starts with a full profile:
const [profile, setProfile] = useState<UserProfile | null>(null);
When a form field changes, we can dispatch a deep partial update:
function updateProfile(partial: DeepPartial<UserProfile>) {
setProfile(prev => {
if (!prev) return null;
// Simple recursive merge – you could replace with a library like lodash
return { ...prev, ...partial };
});
}
Because partial is typed as DeepPartial<UserProfile>, the editor will only suggest nested fields that actually exist in the original shape. If I accidentally write updateProfile({ address: { country: 'USA' } }), TypeScript complains: Property 'country' does not exist on type 'Address'. That immediate feedback saves countless debugging hours.
The same pattern works for API payloads. When we need to send only the changed fields to the backend, we can pass the deep partial directly:
async function saveProfile(update: DeepPartial<UserProfile>) {
const response = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update),
});
// handle response...
}
Tip: If you want stricter updates for certain keys, consider marking them asreadonlyin the original type. The deep partial will still make them optional, but you can enforce immutability by usingas conston nested literals.
Bonus: A Helper for Merging Deep Partials
The simple spread you see above works for shallow merges, but nested objects get overwritten rather than merged. A tiny utility function makes the pattern production‑ready:
function deepMerge<T>(target: T, source: DeepPartial<T>): T {
const result = Array.isArray(target) ? [] : {} as any;
for (const key in source) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const srcVal = source[key];
const tgtVal = target[key];
if (srcVal === undefined) {
result[key] = tgtVal;
} else if (
srcVal &&
typeof srcVal === 'object' &&
!Array.isArray(srcVal) &&
tgtVal &&
typeof tgtVal === 'object' &&
!Array.isArray(tgtVal)
) {
result[key] = deepMerge(tgtVal, srcVal);
} else {
result[key] = srcVal as any;
}
}
return { ...tgtVal, ...result };
}
Now updateProfile can safely combine a full profile with a deep partial without losing sibling fields:
function updateProfile(partial: DeepPartial<UserProfile>) {
setProfile(prev =>
prev ? deepMerge(prev, partial) : null
);
}
This utility is small enough to keep in a types.ts file and can be reused across components, Redux slices, or API clients.
When to Reach for Deep Partial
Deep partial shines in several common situations:
- Form handling – when a form mirrors a nested API shape, you want each input to accept a partial of its parent.
- State management – Redux or Zustand reducers often need to patch nested state without rewriting the entire object.
- Testing utilities – constructing minimal fixtures for unit tests becomes type‑safe.
- Configuration objects – optional nested configs where only a subset changes at runtime.
In each case, the benefit is the same: **type‑safe flexibility**. The editor guides you, refactoring tools respect the shape, and runtime errors drop dramatically.
Wrapping Up
Recursive DeepPartial is a small addition to a TypeScript toolkit that pays huge dividends when you work with nested data. By making every level optional, you keep your update APIs clean, maintainable, and fully type‑checked. I started using it a year ago, and since then I rarely see any in my update paths. If your codebase deals with objects deeper than one level, give it a try—you’ll notice the difference in both developer experience and code reliability.
Feel free to copy the type definitions into a shared types.ts file, add the merge helper if you need it, and start enjoying the peace of mind that comes with truly deep optional types.