Generate every path string lodash _.get accepts on an object, dot and bracket notation included, by walking the type with recursive template literals.
Compute every 'person.pets.0.type'-style path an object supports, and reject the malformed ones, entirely at the type level.
Typed path strings show up in real libraries: react-hook-form validates field paths against your form type using exactly this technique. ObjectKeyPaths<T> produces all possible paths that could be passed to _.get (a lodash function) to read a value out of an object. That means walking nested objects and arrays recursively, joining keys with dots, and supporting bracket notation like books[0] and books.[0] for numeric indices. It's a workout in recursive template literal types and union distribution.
type T1 = ObjectKeyPaths<{ name: string; age: number }>; // expected to be 'name' | 'age'
type T2 = ObjectKeyPaths<{
refCount: number;
person: { name: string; age: number };
}>; // expected to be 'refCount' | 'person' | 'person.name' | 'person.age'
type T3 = ObjectKeyPaths<{ books: [{ name: string; price: number }] }>; // expected to be the superset of 'books' | 'books.0' | 'books[0]' | 'books.[0]' | 'books.0.name' | 'books.0.price' | 'books.length' | 'books.find'Get all possible paths that could be called by _.get (a lodash function) to get the value of an object
type T1 = ObjectKeyPaths<{ name: string; age: number }>; // expected to be 'name' | 'age'
type T2 = ObjectKeyPaths<{
refCount: number;
person: { name: string; age: number };
}>; // expected to be 'refCount' | 'person' | 'person.name' | 'person.age'
type T3 = ObjectKeyPaths<{ books: [{ name: string; price: number }] }>; // expected to be the superset of 'books' | 'books.0' | 'books[0]' | 'books.[0]' | 'books.0.name' | 'books.0.price' | 'books.length' | 'books.find'View on GitHub: https://tsch.js.org/7258
Change the following code to make the test cases pass (no type check errors).
π Lifetime-License is leaving on August 10, 2026
Get it now for $29
One-time payment. Lifetime access to all pro challenges.
Two types make up the solution:
type GenerateKey<K extends string | number, IsRoot extends boolean> =
IsRoot extends true
? `${K}`
: `.${K}` | (K extends number ? `[${K}]` | `.[${K}]` : never)
type ObjectKeyPaths<
T extends object,
IsRoot extends boolean = true,
K extends keyof T = keyof T,
> = K extends string | number
?
| GenerateKey<K, IsRoot>
| (T[K] extends object
? `${GenerateKey<K, IsRoot>}${ObjectKeyPaths<T[K], false>}`
: never)
: neverGenerateKey decides how a single key may be spelled at its position; ObjectKeyPaths walks the object and glues spellings together.
A path segment looks different depending on where it sits. The root key has no separator ('person'), while nested keys need one, and numeric keys additionally support lodash's bracket forms. GenerateKey encodes exactly that:
// GenerateKey<'name', true> = 'name'
// GenerateKey<'name', false> = '.name'
// GenerateKey<0, false> = '.0' | '[0]' | '.[0]'The K extends number check is a distributive conditional that adds the bracket spellings only for numeric keys: 'person[name]' is not a valid lodash path, but 'books[0]' is. Keeping this concern in its own helper keeps the main type readable.
ObjectKeyPaths carries two extra parameters with defaults, a common trick for recursive types:
IsRoot starts as true and is passed as false on every recursive call, so only top-level keys are rendered without a leading separator.K extends keyof T = keyof T isn't something callers provide. It exists to trigger distribution: because K is a bare type parameter in K extends string | number ? ... : never, TypeScript evaluates the body once per key in the union and unions the results. Each key is processed independently, and symbol keys are filtered out along the way.For every key, the result contributes two things:
GenerateKey<K, IsRoot>, the path that stops here. Every prefix of a valid path is itself a valid path ('person' is retrievable, not just 'person.name').`${GenerateKey<K, IsRoot>}${ObjectKeyPaths<T[K], false>}`. The recursive call returns segments that already start with their own separator (since IsRoot is false), so plain string concatenation lines everything up. No separator logic is needed at the join point.Tracing { refCount: number; person: { name: string; age: number } }:
// K = 'refCount' β 'refCount' (number, no recursion)
// K = 'person' β 'person'
// | `person${'.name' | '.age'}` β 'person.name' | 'person.age'That's exactly the expected 'refCount' | 'person' | 'person.name' | 'person.age'.
Nothing special, and that is the point. An array is an object whose keyof includes the index signature key number, plus 'length' and method names like 'find'. Distribution over those keys gives:
number β `.${number}` | `[${number}]` | `.[${number}]`: one pattern type that every concrete index string matches. 'person.books.0', 'person.books.1' and 'person.books[0]' all extend `person.books.${number}` and friends. If the element type is an object, recursion continues through it, which is how 'person.pets.0.type' becomes valid.'length', 'find', β¦: ordinary string keys, yielding paths like 'books.length'. Function-typed values are technically objects, so the recursion peeks inside them, but functions have no own keys, so that branch quietly evaluates to never.This is why the third example says "superset": the array tests use ExpectExtends rather than Equal, checking that each valid path is accepted without pinning down the whole (large) union.
'notExist' and 'person.notExist' are rejected: only keys that actually exist ever enter the union.'person.name.' and '.person.name' are rejected: separators are only ever produced between segments. A trailing dot matches no pattern, and a leading dot can't appear because the root uses the bare `${K}` spelling.'person.pets.[0]type' is rejected: after a bracket segment, the continuation produced by the recursion always begins with its own separator (.type), so the dot between [0] and type is mandatory.The core recipe, a spelling helper plus a distributing walker that concatenates template literals, transfers directly to building typed get/set utilities and form-path types in production code.
This challenge is originally from here.