Type-safe nested object paths enable compile-time validation of property access patterns. Whether you're building form libraries, state management utilities, or data transformation tools, ensuring that dot-notation paths like user.profile.name are valid at compile time eliminates an entire class of runtime errors.
In this article, we'll build a type system that generates all possible nested paths for any object structure using TypeScript's template literal types, conditional types, and recursive type patterns.
The Problem: Validating Nested Object Paths
Consider a complex nested object structure:
type NestedObjectType = {
a: {
b: {
c: string
}
}
d: string
}This object has multiple valid paths: a, a.b, a.b.c, and d. When working with libraries that accept path strings (like form validation libraries, lodash's get/set, or state management utilities), we want TypeScript to validate these paths at compile time. Our goal is to create a type that generates a union of all valid paths, rejecting invalid ones like a.b.x or a.d.
Building Blocks
Understanding the never Type
The never type in TypeScript represents values that never occur. It's the bottom type in TypeScript's type hierarchy: a subtype of every type, but no type is a subtype of never (except never itself). Think of it as an empty set in mathematics.
let impossible: never
// Cannot assign any value to 'impossible'In our path type system, never serves as a signal for invalid conditions. When a key isn't a string or number, or when we reach a primitive value that can't be traversed further, we return never to exclude those branches from the final union type.
The Join Type: Concatenating Path Segments
To build nested paths, we need a way to join segments with a dot separator. This is where TypeScript's template literal types shine:
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}.${P}`
: never
: neverThis type uses nested conditional types to validate and join path segments:
- First check:
K extends string | numbervalidates that the keyKis either a string or number (valid object keys) - Second check:
P extends string | numbervalidates that the path segmentPis also a string or number - Success case: If both checks pass, we use template literal syntax to concatenate them:
`${K}.${P}` - Failure case: If either check fails, we return
neverto exclude this branch from the final type
For example, Join<"user", "name"> produces "user.name", while Join<symbol, "name"> produces never.
The Paths Type: Recursive Path Generation
Now we can build the core type that recursively explores object structures and generates all possible paths:
type Paths<T> = T extends object
? {
[K in keyof T]-?: K extends string | number
? `${K}` | Join<K, Paths<T[K]>>
: never
}[keyof T]
: ''This type is dense, so let's break it down step by step:
1. Object Type Guard
T extends object ? ... : ''First, we check if T is an object type. If it's a primitive (string, number, boolean, etc.), we return an empty string '' to terminate recursion. This prevents us from trying to access properties on primitives.
2. Mapped Type Over Keys
{ [K in keyof T]-?: ... }For object types, we create a mapped type that iterates over each key K in T. The -? modifier is crucial: it removes optionality from all properties. This ensures that optional properties are still included in our path generation, treating optional?: string the same as optional: string.
3. Key Validation
K extends string | number ? ... : neverWe filter out keys that aren't strings or numbers (like symbols), as these can't be represented in dot-notation paths.
4. Path Construction
`${K}` | Join<K, Paths<T[K]>>For each valid key, we generate two types:
- Direct path:
`${K}`converts the key to a string literal type (e.g.,"user") - Nested paths:
Join<K, Paths<T[K]>>recursively generates deeper paths by:- Calling
Paths<T[K]>to get all paths within the value at keyK - Using
Jointo prepend the current key to each nested path
- Calling
For example, if K is "user" and T[K] has a property "name", this produces both "user" and "user.name".
5. Union Extraction
}[keyof T]The indexed access [keyof T] at the end extracts all the property types from our mapped type and combines them into a union. This is a common TypeScript pattern for "flattening" a mapped type into a union of its values.
Think of it this way: the mapped type creates { a: "a" | "a.b", d: "d" }, and [keyof T] extracts "a" | "a.b" | "d".
Putting It All Together
Let's see our type system in action with the NestedObjectType from earlier:
type NestedObjectType = {
a: {
b: {
c: string
}
}
d: string
}
// TypeScript infers: "a" | "d" | "a.b" | "a.b.c"
type AllPaths = Paths<NestedObjectType>
// ✅ Valid: All paths exist in the type
const validPaths: Paths<NestedObjectType>[] = ['a', 'd', 'a.b', 'a.b.c']
// ❌ Error: Type '"a.b.x"' is not assignable to type 'Paths<NestedObjectType>'
const invalidPaths: Paths<NestedObjectType>[] = ['a.b.x']TypeScript catches the invalid path 'a.b.x' at compile time, preventing potential runtime errors.
Practical Use Cases
This pattern is useful for:
- Form libraries: Validating field paths in libraries like React Hook Form or Formik
- State management: Type-safe selectors for nested state (Redux, Zustand, etc.)
- Data transformation: Ensuring valid paths in utilities like lodash's
get,set, orpick - API clients: Type-safe field selection in GraphQL-like query builders
Example with type-safe path validation:
const obj: NestedObjectType = {
a: { b: { c: 'hello' } },
d: 'world'
}
// Define a function that only accepts valid paths
function processPath(path: Paths<NestedObjectType>) {
console.log(`Processing path: ${path}`)
}
processPath('a.b.c') // ✅ Valid
processPath('a.b.x') // ❌ TypeScript error: Argument of type '"a.b.x"' is not assignableLimitations and Edge Cases
This approach has some limitations to be aware of:
1. Arrays
Arrays are technically objects, so Paths will generate numeric index paths:
type ArrayExample = {
items: string[]
}
// Generates: "items" | "items.0" | "items.1" | ... (many numeric paths)
type ArrayPaths = Paths<ArrayExample>To handle arrays more gracefully, you might want to stop recursion at array types:
type Paths<T> = T extends any[]
? ''
: T extends object
? {
[K in keyof T]-?: K extends string | number
? `${K}` | Join<K, Paths<T[K]>>
: never
}[keyof T]
: ''2. Circular References
TypeScript has a recursion depth limit. Deeply nested or circular type references may hit this limit, causing compilation errors. For most real-world use cases, the default depth is sufficient.
3. Performance
Complex types with many nested levels can slow down TypeScript's type checker. If you notice slow IDE performance, consider limiting recursion depth or simplifying your types.
Conclusion
By combining conditional types, template literal types, mapped types, and recursion, we've built a type system that validates nested object paths at compile time.
This pattern eliminates an entire class of runtime errors, improves developer experience with autocomplete, and serves as documentation for valid paths in your codebase. The core technique is production-ready and widely used in popular TypeScript libraries.
