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.

Avatar
Dennis GaidelOctober 6, 2023

In TypeScript, defining types for nested object paths is one of those topics that's both intriguing and practical. It’s about ensuring that when we reference object paths, especially in arrays, we're on the right track. No wild guesses, no runtime errors due to non-existent properties. In this post, we'll break down how to create a type that effectively lists all possible nested dot paths of an object, making our TypeScript journey a tad smoother.

Ensuring Valid Nested Object Paths

Picture a complex nested object:

type NestedObjectType = {
    a: {
        b: {
            c: string
        }
    }
    d: string
}

Within this object, we can navigate multiple paths: a, a.b, a.b.c, and d. What if we want to create an array that lists specific paths, but we want to ensure that each listed path is valid?

The Power of "never"

The never type in TypeScript is a subtype of every other type but can be assigned no value other than itself. Think of it as an empty set; nothing belongs in it.

Example:

let impossible: never

You can't assign any value to the impossible variable because there's no valid value that TypeScript would accept for a never type. This feature becomes invaluable when signaling invalid paths or conditions.

Constructing Our Path Types

Joining Path Segments: The Join Type

The very foundation of creating paths is the ability to concatenate segments. This is achieved through TypeScript's template literals.

type Join<K, P> = K extends string | number
    ? P extends string | number
        ? `${K}.${P}`
        : never
    : never

In this type:

  • We're creating a conditional type using K extends string | number, which checks if K (our key) is either a string or a number.
  • If K is a valid key (string or number), we proceed to the next check: P extends string | number. Here, P stands for the next segment of our path.
  • If both conditions are met, we concatenate the segments using a dot: ${K}.${P}.
  • If any condition fails, the type results in never, indicating an invalid path.

Exploring Object Structures: The Paths Type

With a way to join segments, we delve into creating the paths:

type Paths<T> = T extends object
    ? {
          [K in keyof T]-?: K extends string | number
              ? `${K}` | Join<K, Paths<T[K]>>
              : never
      }[keyof T]
    : ''

Object Check

We start by checking if T is an object with T extends object. If it isn't, we fall back to an empty string "".

Mapping Over Keys

If T is an object, we then iterate over its keys using [K in keyof T].

Optionality Removal

The -? after [K in keyof T] removes optionality. This means that even if certain properties of T are optional, we still treat them as required when constructing paths.

Constructing Path for Each Key

For each key K, we have another conditional check: K extends string | number. This ensures our key is valid for constructing a path.

Recursive Exploration

If K is a valid key, we proceed to construct the path. Here, ${K} is the direct path to the key, and Join<K, Paths<T[K]>> is a recursive call to construct deeper paths.

The expression can be viewed as two parts:

Template Literal Types

${K} uses TypeScript's template literal types. In this case, it simply takes the key (K) and represents it as a string.

Recursive Path Construction

This part delves deeper into the object to construct nested paths.

  • Join<K, Paths<T[K]>>: This calls the previously defined Join type, which concatenates two string segments with a dot.
  • Paths<T[K]>: This is a recursive call to the Paths type. Here, T[K] accesses the type of the current key in the object.

    By feeding this into Paths, we're essentially asking: "What are the nested paths of this current key?"

Collecting All Paths

Finally, the [keyof T] at the end takes the union of all paths created for each key of T.

  • The { [K in keyof T]-?: ...; } part creates a new mapped type where each key in T gets some type (as determined by the logic inside the curly braces).
  • By immediately following it with [keyof T], we're effectively saying "get me the types of all those properties together as a union".

Think of it as collapsing the new mapped type's properties into a single union type. In the context of the Paths type, it means collecting all the possible paths into a single union type.

Example

With our types in place, we can now define and validate nested paths:

const validPaths: Paths<NestedObjectType>[] = ['a', 'd', 'a.b', 'a.b.c']

Attempting to define an invalid path will raise a TypeScript error:

const invalidPaths: Paths<NestedObjectType>[] = ['a.b.x'] // Error!

Conclusion

With TypeScript's powerful type system, we can ensure the paths we navigate in nested objects are always valid.

Through the utilization of conditional types, template literals, and the unique characteristics of the never type, we can create robust and self-validating code structures.