#7258β€’Hard

Object Key Paths

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'

Challenge Instructions: Object Key Paths

Hard

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.

Loading...

Detailed Explanation

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

GenerateKey decides how a single key may be spelled at its position; ObjectKeyPaths walks the object and glues spellings together.

Sub-problem 1: how can one key be written?

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.

Sub-problem 2: walking the object

ObjectKeyPaths carries two extra parameters with defaults, a common trick for recursive types:

For every key, the result contributes two things:

  1. 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').
  2. If the value is an object, the continuation: `${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'.

What happens with arrays

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:

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.

Edge cases the tests cover

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.

Share this challenge

Learn the Concepts