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 ifK
(our key) is either a string or a number. - If
K
is a valid key (string
ornumber
), 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 thePaths
type. Here,T[K]
accesses the type of the current key in the object.
By feeding this intoPaths
, 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 inT
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.