How would you write a recursive type that produces the union of dotted key paths ("user.address.city") for a nested object type, and what breaks when the object contains arrays or a self-referencing node?
answer
- map keys, then index to collapse
- & string filters non-dotted keys
- split the head segment with infer
- arrays contribute their method names
- cycles need an explicit depth cap
basics
~20 sRecurse over the keys, emitting each key and, for object-valued keys, that key joined to the child's paths with a dot. Arrays leak Array's own member names into the union, and a self-referencing type recurses forever until the compiler reports an excessive-depth error.
solid answer
~50 sThe shape is a mapped type whose values are the paths, immediately indexed to collapse it into a union: for each string key `K`, emit `K`, and when `T[K]` is an object also emit `` `${K}.${Path<T[K]>}` ``. A companion `PathValue<T, P>` splits the path on the first dot with `infer` and walks down to return the type at that path, which is what makes a typed `get(obj, path)` possible. The failure modes are what interviewers actually want: arrays are objects, so the helper recurses into `Array`'s own members and you get junk like `"tags.length"` and `"tags.push"` unless you special-case them with `` `${number}` ``; a self-referencing type such as `interface Node { parent: Node }` never reaches a base case and triggers the excessive-depth error; and an index signature collapses the branch to `` `${string}` ``, which swallows every other path in the union.
code
typescript · 27 linestype Path<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? K | `${K}.${Path<T[K]>}`
: K;
}[keyof T & string]
: never;
type PathValue<T, P extends string> =
P extends `${infer K}.${infer Rest}`
? K extends keyof T
? PathValue<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
declare function get<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P>;
interface Config {
theme: { colors: { bg: string; fg: string } };
retries: number;
}
declare const config: Config;
const bg: string = get(config, "theme.colors.bg");
const retries: number = get(config, "retries");go deeper
Know what such a type produces — a union of dotted strings — and that it is built by recursing over keys rather than written out by hand. You are not expected to author it unaided.
Explain the mechanics: the mapped type indexed by its own keys to form a union, the & string filter, and the mirrored PathValue recursion that splits the head segment with infer.
Lead with the failure modes — array members leaking in, cyclic types hitting the depth ceiling, index signatures collapsing to a pattern, optional properties dropping subtrees — and show the depth-capped version you would actually ship.
Own the cost question: the union is close to a cross-product of keys, so decide what depth the organisation supports, how the editor-performance cost is monitored, and where a plainer API beats a fully typed path string.
## The goal Given a nested type, produce every legal dotted access path as a string-literal union, so an API like `get(config, "theme.colors.bg")` can autocomplete the path and return the right value type. ## Building the union of paths ```typescript type Path<T> = T extends object ? { [K in keyof T & string]: T[K] extends object ? K | `${K}.${Path<T[K]>}` : K; }[keyof T & string] : never; ``` Three mechanisms are at work. The mapped type builds an object whose *values* are the paths for each key. Indexing that object with `[keyof T & string]` immediately collapses it into the union of those values — the standard idiom for turning per-key results into one union. And `& string` filters out `number` and `symbol` keys, which cannot appear in a dotted path anyway; without it, the template literal would be malformed. The recursion sits under the template literal, so `Path<T[K]>` is computed for each nested object and prefixed with the parent key. The inner conditional matters: if `T[K]` is not an object, emitting only `K` avoids producing a dangling `"key."` prefix from a `never` child. ## Reading the value back A path union is only half the feature; a typed getter needs the type *at* the path: ```typescript type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? PathValue<T[K], Rest> : never : P extends keyof T ? T[P] : never; declare function get<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P>; ``` This is the mirror recursion: split off the head segment, descend one level, repeat; when there is no dot left, index directly. Both recursions terminate on ordinary finite object types because each step strictly shrinks the remaining path or the remaining depth. ## Where it breaks **Arrays.** `string[] extends object` is true, and `keyof string[] & string` is every `Array` member name — so the union fills with `"tags.length"`, `"tags.push"`, `"tags.concat"`. The fix is an explicit branch before the object branch: ```typescript type Path<T> = T extends readonly (infer U)[] ? `${number}` | `${number}.${Path<U>}` : T extends object ? { [K in keyof T & string]: T[K] extends object ? K | `${K}.${Path<T[K]>}` : K }[keyof T & string] : never; ``` Whether you want indices in paths at all is a design decision — many helpers stop at the array and let the caller index it in code. **Cycles.** `interface Node { name: string; parent: Node }` has no base case: expanding `parent` produces `parent.parent`, then `parent.parent.parent`, forever. The compiler stops with `Type instantiation is excessively deep and possibly infinite`, and the resulting type degrades. The remedy is a depth counter: ```typescript type Prev = [never, 0, 1, 2, 3, 4, 5]; type Path<T, D extends number = 4> = [D] extends [never] ? never : T extends object ? { [K in keyof T & string]: T[K] extends object ? K | `${K}.${Path<T[K], Prev[D]>}` : K }[keyof T & string] : never; ``` `Prev` acts as a decrement operator; `[D] extends [never]` is tuple-wrapped so the conditional does not distribute over the numeric union. **Index signatures.** For `Record<string, Config>`, `keyof T & string` is `string`, and the template literal produces `` `${string}` `` — a pattern that matches essentially anything. Since a union containing that pattern absorbs the useful literals, autocomplete dies. Either exclude index-signature branches or accept that those subtrees are unchecked. **Optional and nullable properties.** `T[K]` for `theme?: Theme` is `Theme | undefined`, which fails `extends object` as a union, so the subtree silently disappears from the path union. Strip the modifier first — recurse into `NonNullable<T[K]>` — if you want optional subtrees to have paths. Then remember that the *value* type returned by `PathValue` should still carry the `undefined`, because at runtime the intermediate object may be missing. **Union-typed nodes.** A property typed `A | B` distributes in the conditional, producing paths valid for one member but not the other. That is often desirable, but it means `get` accepts a path that may not exist on the value you actually hold. ## The size of the thing you just built The path union is roughly the cross-product of keys across levels. A modest configuration object with five levels and ten keys each produces a union in the tens of thousands of literals; every hover, autocomplete and assignability check has to work with it. This is a real editor-responsiveness cost, and it is why depth caps matter more here than in most recursive types. ## The erasure reminder None of this validates anything at runtime. `get(obj, "a.b.c")` still has to split the string and walk the object, and if an intermediate value is missing the code throws unless you guard. The type says which paths are *spellable*, not which are *present*.
- Why is `[keyof T & string]` applied to the mapped type instead of returning the mapped type itself?The mapped type is an object whose values are the per-key path unions; indexing it by all its keys collapses those values into a single union, which is the shape a path parameter needs. Without the index you would hand callers an object type, not a set of allowed strings.
- How should PathValue treat a path that passes through an optional property?The declared value type should keep `undefined` in it, because at runtime the intermediate object may be absent and the getter will either throw or return undefined. Recursing through `NonNullable<T[K]>` for path *generation* while adding `| undefined` to the *result* keeps the paths spellable and the return type honest.
- What happens to the path union when a property is typed with an index signature such as Record<string, Config>?`keyof T & string` becomes `string`, so the branch produces the pattern `` `${string}` ``. That pattern matches nearly any string, and a union containing it absorbs the useful literals — autocomplete stops suggesting anything meaningful for that subtree. Either exclude index-signature branches or accept that they are effectively unchecked.
- How large does such a union get, and why does it matter?It is close to the cross-product of keys across levels, so a few levels of a normal config object reach tens of thousands of literals. Every hover, completion and assignability check works over that set, which is felt directly as editor lag — the main reason to cap depth rather than let the type recurse as far as the data allows.
saying these in an interview costs you the question
- Assumes arrays contribute only numeric indices
- Expects the recursion to stop on a cyclic type
- Thinks the path union costs nothing to check
- Forgets & string, producing malformed templates
- Believes get is safe when a level is missing