evolved.io logotype

#TypeScript #Advanced-Types #Path-Typing #Template-Literals

Mastering Nested Object Paths with TypeScript

A detailed guide on using TypeScript types to define and validate nested dot paths in object structures.

Dennis Gaidel
Dennis GaidelOctober 6, 2023

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
    : never

This type uses nested conditional types to validate and join path segments:

  1. First check: K extends string | number validates that the key K is either a string or number (valid object keys)
  2. Second check: P extends string | number validates that the path segment P is also a string or number
  3. Success case: If both checks pass, we use template literal syntax to concatenate them: `${K}.${P}`
  4. Failure case: If either check fails, we return never to 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 ? ... : never

We 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 key K
    • Using Join to prepend the current key to each nested path

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:

  1. Form libraries: Validating field paths in libraries like React Hook Form or Formik
  2. State management: Type-safe selectors for nested state (Redux, Zustand, etc.)
  3. Data transformation: Ensuring valid paths in utilities like lodash's get, set, or pick
  4. 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 assignable

Limitations 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.