Type-Safe lodash.get in TypeScript: Deep Object Paths with Full Inference

You’re deep in a TypeScript project. You’ve carefully typed everything — interfaces, API responses, state shapes. Then someone writes this:

const value = _.get(user, 'profile.address.city');
// value: any

All your careful typing just evaporated. The return type is any, the path is an unvalidated magic string, and the compiler has gone completely blind. Rename profile to profileData tomorrow and this silently breaks at runtime — no error, no warning, nothing.

Lodash _.get is useful. But in a properly typed TypeScript codebase, it’s a type-system hole. The good news: TypeScript’s type system is powerful enough to do exactly what lodash can’t — infer the return type from a dot-notation string path, validate that path at compile time, and surface rename-breakage as a type error, not a production incident.

This article builds that solution from the ground up.

Why lodash Can’t Do This

Lodash was written for JavaScript and the types in @types/lodash are doing their best with a fundamentally weakly-typed API. The signature looks roughly like:

get(object: any, path: string | string[], defaultValue?: any): any;

There’s nothing to hang inference on. The path is a runtime string. By the time TypeScript sees it, the information about what type should come back is gone.

The overloads in @types/lodash handle some shallow cases (obj['key']) but the moment you go more than one level deep with dot notation, you’re back to any.

What We’re Actually Building

The goal: a get function where the TypeScript compiler:

  1. Validates that the dot-notation path actually exists on the object type
  2. Infers the exact return type at that path
  3. Refuses to compile if the path is wrong or the object shape changes
const user = {
  id: 1,
  profile: {
    name: 'Nikita',
    address: { city: 'Berlin', zip: '10115' }
  }
};

const city = get(user, 'profile.address.city');
// city: string  ✅

const bad = get(user, 'profile.address.country');
// Argument of type '"profile.address.country"' is not assignable to...  ✅

Zero runtime cost — it’s all compile-time.

Step 1: Generating Valid Paths as a Type

First, we need a type that, given an object type T, produces a union of all valid dot-notation path strings.

// Depth-limiting helper — prevents TypeScript from going infinite on recursive types
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]];

type Paths<T, D extends number = 5> =
  [D] extends [never]
    ? never
    : T extends object
    ? {
        [K in keyof T]-?: K extends string | number
          ? `${K}` | `${K}.${Paths<T[K], Prev[D]>}`
          : never;
      }[keyof T]
    : never;

Let’s break this down:

Prev is a tuple used as a lookup table. Prev[5] is 4, Prev[1] is 0, Prev[0] is never. It’s how you decrement a number at the type level — TypeScript has no arithmetic, but indexed tuple access works.

[D] extends [never] is the base case. When we’ve recursed down to never, stop producing paths.

The mapped type walks every key K of T. For each key, it produces the key itself ("profile") and then recursively all sub-paths prefixed with "profile.". The -? removes optionality from keys so we don’t miss paths under optional fields.

Test it:

type UserPaths = Paths<typeof user>;
// "id" | "profile" | "profile.name" | "profile.address" | "profile.address.city" | "profile.address.zip"

Exact. Every valid path, nothing invalid.

Step 2: Resolving the Value Type at a Path

Given a path string like "profile.address.city", we need to walk the object type and figure out what type lives there.

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;

This uses template literal type inference. When P matches the pattern ${K}.${Rest}:

  • Extract the head key K and the remaining path Rest
  • Check that K is actually a key of T
  • Recurse with T[K] and Rest

When P doesn’t contain a dot, it’s the final key — just look it up directly in T.

type CityType = PathValue<typeof user, 'profile.address.city'>;
// string

type BadType = PathValue<typeof user, 'profile.address.country'>;
// never

Step 3: Writing the get Function

Now wire it together:

function get<T extends object, P extends Paths<T>>(
  obj: T,
  path: P
): PathValue<T, P & string> {
  const keys = (path as string).split('.');
  let result: unknown = obj;

  for (const key of keys) {
    if (result === null || result === undefined) return undefined as never;
    result = (result as Record<string, unknown>)[key];
  }

  return result as PathValue<T, P & string>;
}

The P extends Paths<T> constraint is doing all the work. TypeScript will only accept a path argument that is a valid member of Paths<T>. Pass anything else and you get a compile error pointing directly at the bad path string.

The as never in the early return is a deliberate type assertion — we’re returning undefined for a missing intermediate value but we’re telling the type system "trust me" here. In production code you’d pair this with a default value parameter (covered below).

Handling Arrays

The above works for plain objects but falls apart on arrays. user.roles[0].name — the [0] part isn’t handled.

Arrays in TypeScript have numeric index signatures. A clean solution: treat array access as numeric keys, which stringifies to "0", "1", etc. That works for fixed-length tuples but is awkward for dynamic arrays.

A pragmatic approach — allow both dot notation and bracket notation paths for arrays by mapping array types to their element type:

type Paths<T, D extends number = 5> =
  [D] extends [never]
    ? never
    : T extends readonly (infer Item)[]
    ? `${number}` | `${number}.${Paths<Item, Prev[D]>}`
    : T extends object
    ? {
        [K in keyof T]-?: K extends string | number
          ? `${K}` | `${K}.${Paths<T[K], Prev[D]>}`
          : never;
      }[keyof T]
    : never;

The T extends readonly (infer Item)[] branch catches arrays and generates "0", "1", etc. via ${number} — which TypeScript expands to the string representation of any number literal.

const data = { users: [{ name: 'Nikita' }, { name: 'Anna' }] };

const name = get(data, 'users.0.name');
// name: string  ✅

The Depth Limit Problem

TypeScript has a hard recursion limit on conditional types. If your object is deeply nested or has many keys, the Paths<T> type will either time out the compiler or produce an Type instantiation is excessively deep error.

The D extends number = 5 parameter is your safety valve. The default of 5 levels is enough for most real-world objects. You can raise it to 8 or 10 for deeper structures, but watch compile times — this is O(keys^depth).

Practical guideline: if your nested path goes deeper than 4-5 levels in a real domain model, the object design probably has its own problems. Flat(er) state shapes and proper slicing generally win over deeply recursive access.

Gotchas

Union types on intermediate keys. If a key in the middle of your path is a union (string | number), path generation can blow up into a cartesian product of strings. The compiler may time out. Narrow the type before calling get, or constrain the union before it gets into your data model.

Optional fields. The -? in the mapped type forces all keys to be treated as required for path generation purposes. This means get(obj, 'optionalField.subkey') will typecheck even if optionalField could be undefined at runtime. The function itself handles this gracefully with the early return, but the return type won’t be T | undefined — it’ll be the raw value type. Add an explicit | undefined to the return type if that matters to you:

function get<T extends object, P extends Paths<T>>(
  obj: T,
  path: P
): PathValue<T, P & string> | undefined {
  // ...
}

Circular types. Any self-referential type (type Tree = { children: Tree[] }) will immediately hit the depth limit. The Prev trick handles this — it’ll stop generating paths at depth D without crashing, but you lose visibility into paths beyond that depth.

any contamination. If your object type contains any anywhere (e.g., from a poorly-typed API response), PathValue will return any for paths through that node. The type system can’t see through any. Fix the upstream types.

Performance with large objects. The type computation is quadratic in the number of keys times depth. A 50-key object at depth 5 can slow down tsc noticeably. For large config objects or API responses with hundreds of keys, consider constraining D to 3 or typing specific subsets explicitly.

Adding a Default Value

Production use almost always wants a fallback:

function get<T extends object, P extends Paths<T>, D = undefined>(
  obj: T,
  path: P,
  defaultValue?: D
): PathValue<T, P & string> | D {
  const keys = (path as string).split('.');
  let result: unknown = obj;

  for (const key of keys) {
    if (result === null || result === undefined) {
      return (defaultValue ?? undefined) as D;
    }
    result = (result as Record<string, unknown>)[key];
  }

  return (result ?? defaultValue) as PathValue<T, P & string> | D;
}

Now the return type properly reflects that a default was provided:

const city = get(user, 'profile.address.city', 'Unknown');
// city: string  (PathValue is string, default is string — union collapses)

const missing = get(user, 'profile.address.city');
// missing: string | undefined

Existing Libraries Worth Knowing

If you’d rather not maintain this utility yourself, a few libraries have production-grade implementations.

ts-toolbelt (GitHub) has Object.Path and Object.PathValid with similar semantics. The library itself is enormous (it’s basically a TypeScript stdlib) but tree-shakes fine. The path API is more explicit — you pass path as a tuple ['profile', 'address', 'city'] rather than a dot string, which sidesteps some of the string-splitting complexity.

radash (GitHub) is a modern lodash replacement with better TypeScript support throughout. Its get isn’t fully path-inferred (it still leans on generics with manual type params in some cases) but it at least doesn’t return any unconditionally.

type-fest (GitHub) ships Get<ObjectType, Path> as a utility type — you can use it to type your own wrapper without implementing the recursion yourself.

For most teams: implement the ~30 lines above in a utils/get.ts, add a unit test, and own it. It’s simple enough that a dependency isn’t worth the upgrade churn.

When NOT to Use This

Deep dot-notation access is a code smell in certain architectures. If you’re reaching five levels deep into an object to get a value, ask whether the data structure is right.

Also: optional chaining (?.) is natively type-safe and handles nullable intermediates better than any get utility. For straightforward static paths known at write time, user?.profile?.address?.city is idiomatic TypeScript, comes free, and compiles to clean JS. Use the get utility specifically when:

  • The path is computed at runtime (e.g., driven by config or user input)
  • You’re iterating over a list of paths (['a.b', 'c.d']) and need the union type to be useful
  • You’re replacing existing lodash _.get calls in a migration and want a drop-in with type safety

For static, known-at-write-time property access: just use ?..

Putting It Together

Here’s the complete, production-ready utility in one file:

// utils/get.ts

// Depth-limiter: prevents infinite recursion in Paths<T>
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]];

/**
 * Generates a union of all valid dot-notation path strings for object type T,
 * up to depth D (default 5).
 */
export type Paths<T, D extends number = 5> =
  [D] extends [never]
    ? never
    : T extends readonly (infer Item)[]
    ? `${number}` | `${number}.${Paths<Item, Prev[D]>}`
    : T extends object
    ? {
        [K in keyof T]-?: K extends string | number
          ? `${K}` | `${K}.${Paths<T[K], Prev[D]>}`
          : never;
      }[keyof T]
    : never;

/**
 * Resolves the value type at dot-notation path P within object type T.
 */
export type PathValue<T, P extends string> =
  P extends `${infer K}.${infer Rest}`
    ? K extends keyof T
      ? PathValue<T[K], Rest>
      : K extends `${number}`
      ? T extends readonly (infer Item)[]
        ? PathValue<Item, Rest>
        : never
      : never
    : P extends keyof T
    ? T[P]
    : P extends `${number}`
    ? T extends readonly (infer Item)[]
      ? Item
      : never
    : never;

/**
 * Type-safe deep object accessor. Path is validated at compile time.
 * Returns the inferred value type at that path, or `defaultValue` if
 * any intermediate key is null/undefined.
 */
export function get<T extends object, P extends Paths<T>, D = undefined>(
  obj: T,
  path: P,
  defaultValue?: D
): PathValue<T, P & string> | D {
  const keys = (path as string).split('.');
  let result: unknown = obj;

  for (const key of keys) {
    if (result === null || result === undefined) {
      return (defaultValue ?? undefined) as D;
    }
    result = (result as Record<string, unknown>)[key];
  }

  return ((result ?? defaultValue) ?? undefined) as PathValue<T, P & string> | D;
}

The array branch in PathValue handles numeric index access on array types — "users.0.name" now resolves correctly even when users is typed as User[].

The Bottom Line

The pattern here — Paths<T> generating a string union, PathValue<T, P> resolving return types, and a generic function that constrains P extends Paths<T> — is a clean application of TypeScript’s structural type system. No magic, no metaprogramming frameworks, just recursive conditional types doing what they’re designed to do.

The compile-time guarantees you get are real: rename a field and every get call that references the old name breaks immediately at build time, not at 2am when a user hits the broken codepath. That’s the trade worth making.

👁 Views: 112,656 · Unique visitors: 45,393