TypeScript can do this... and you're probably not using it
TypeScript can do this… and you’re probably not using it#
If you’re writing TypeScript code but still using string to reference keys in nested objects (like translation files or configurations), you’re missing out on half of the compiler’s power.
Mastering Recursive Template Literal Types allows you to create strict accesses that the IDE understands perfectly, rooting out silent “undefined” errors at runtime. It’s not about over-typing; it’s about designing a developer experience (DX) where the editor won’t let you make mistakes.
The silent bug#
Before looking at the solution, let’s see the problem. Using string to access deep properties hides bugs that TypeScript should easily catch:
// Typical in i18n or configs
function getConfig(path: string) { /* ... */ }
// Compiles perfectly, fails in production
const userUrl = getConfig("api.enpoints.users");
// The error? You wrote "enpoints" instead of "endpoints".
// TS doesn't warn you, and your app returns undefined at runtime.
The pattern: Recursive DeepPath#
The secret lies in combining recursion with Template Literal Types. See how we can extract all possible paths from an object as if they were a dot-joined string:
type type DeepPath<T> = T extends object ? { [K in keyof T & string]: T[K] extends object ? `${K}` | `${K}.${DeepPath<T[K]>}` : `${K}`; }[keyof T & string] : neverDeepPath<function (type parameter) T in type DeepPath<T>T> = function (type parameter) T in type DeepPath<T>T extends object
? {
[function (type parameter) KK in keyof function (type parameter) T in type DeepPath<T>T & string]: function (type parameter) T in type DeepPath<T>T[function (type parameter) KK] extends object
? `${function (type parameter) KK}` | `${function (type parameter) KK}.${type DeepPath<T> = T extends object ? { [K in keyof T & string]: T[K] extends object ? `${K}` | `${K}.${DeepPath<T[K]>}` : `${K}`; }[keyof T & string] : neverDeepPath<function (type parameter) T in type DeepPath<T>T[function (type parameter) KK]>}`
: `${function (type parameter) KK}`;
}[keyof function (type parameter) T in type DeepPath<T>T & string]
: never;
// Usage example:
interface AppConfig {
AppConfig.api: {
endpoints: {
users: string;
posts: string;
};
timeout: number;
}
api: {
endpoints: {
users: string;
posts: string;
}
endpoints: {
users: stringusers: string;
posts: stringposts: string;
};
timeout: numbertimeout: number;
};
AppConfig.theme: "dark" | "light"theme: "dark" | "light";
}
type type ConfigPaths = "api" | "theme" | "api.endpoints" | "api.timeout" | "api.endpoints.users" | "api.endpoints.posts"ConfigPaths = type DeepPath<T> = T extends object ? { [K in keyof T & string]: T[K] extends object ? `${K}` | `${K}.${DeepPath<T[K]>}` : `${K}`; }[keyof T & string] : neverDeepPath<AppConfig>;
Developer Evolution#
I often see configurations annotated manually (const config: AppConfig = ...). This “flattens” the type and erases the specific inference of your actual data.
Why does this matter? Because for DeepPath<T> to work, it needs to know the exact literal structure. That’s where the “Wizard” combo comes in: we use satisfies. It validates that you meet the interface, but keeps the literals intact so DeepPath can fully exploit that precision.
const config: AppConfig = {
api: {
endpoints: {
users: "/users",
posts: "/posts"
},
timeout: 5000
},
theme: "dark"
};
// Problem: 'config.theme' is just string
// not specifically "dark" | "light" here.
const config = {
api: {
endpoints: {
users: "/users",
posts: "/posts"
},
timeout: 5000
},
theme: "dark"
} satisfies AppConfig;
// 'config.theme' remains as "dark"
// And we can navigate any path:
function getProp(path: DeepPath<AppConfig>) {
// lógica...
} 💥 Bonus Track: Return Type Inference (DeepValue)#
Validating the input string is great, but true level 500 is inferring the exact return type based on that string. If I request "api.timeout", TypeScript should know it returns a number.
We combine DeepPath with another recursive type called DeepValue:
type type DeepValue<T, P> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? DeepValue<T[K], Rest> : never : P extends keyof T ? T[P] : neverDeepValue<function (type parameter) T in type DeepValue<T, P>T, function (type parameter) P in type DeepValue<T, P>P> = function (type parameter) P in type DeepValue<T, P>P extends `${infer function (type parameter) KK}.${infer function (type parameter) RestRest}`
? function (type parameter) KK extends keyof function (type parameter) T in type DeepValue<T, P>T
? type DeepValue<T, P> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? DeepValue<T[K], Rest> : never : P extends keyof T ? T[P] : neverDeepValue<function (type parameter) T in type DeepValue<T, P>T[function (type parameter) KK], function (type parameter) RestRest>
: never
: function (type parameter) P in type DeepValue<T, P>P extends keyof function (type parameter) T in type DeepValue<T, P>T
? function (type parameter) T in type DeepValue<T, P>T[function (type parameter) P in type DeepValue<T, P>P]
: never;
// Pure magic:
function function getConfig<P extends DeepPath<AppConfig>>(path: P): DeepValue<AppConfig, P>getConfig<function (type parameter) P in getConfig<P extends DeepPath<AppConfig>>(path: P): DeepValue<AppConfig, P>P extends type DeepPath<T> = T extends object ? { [K in keyof T & string]: T[K] extends object ? `${K}` | `${K}.${DeepPath<T[K]>}` : `${K}`; }[keyof T & string] : neverDeepPath<AppConfig>>(path: P extends DeepPath<AppConfig>path: function (type parameter) P in getConfig<P extends DeepPath<AppConfig>>(path: P): DeepValue<AppConfig, P>P): type DeepValue<T, P> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? DeepValue<T[K], Rest> : never : P extends keyof T ? T[P] : neverDeepValue<AppConfig, function (type parameter) P in getConfig<P extends DeepPath<AppConfig>>(path: P): DeepValue<AppConfig, P>P> {
// implementation (e.g., using lodash/get)
return {} as any;
}
const const url: stringurl = function getConfig<"api.endpoints.users">(path: "api.endpoints.users"): stringgetConfig("api.endpoints.users");
const const theme: "dark" | "light"theme = function getConfig<"theme">(path: "theme"): "dark" | "light"getConfig("theme");
The cost of magic (Trade-offs)#
No advanced pattern comes for free. In production, abusing this recursion has real technical limits that will hit you if the object grows too large.
Template Literal Paths
Pros
- Zero typo-related runtime errors.
- 100% safe and traceable refactoring.
- Native autocompletion without extra plugins.
Cons
- Compiler performance: generates massive unions that slow down your IDE if the base object is huge.
- Recursion limits: TypeScript halts (Type instantiation is excessively deep) if you pass a certain depth.
- Dynamic arrays: inference breaks or gets extremely complicated if you try to navigate array indices (T[]).
This technique drastically reduces headaches. By implementing these types, you force the compiler to validate the actual structure of your data before the build. If you work on mid-sized projects with predictable configurations or i18n systems, this pattern saves you hours of debugging.
If you’re interested in taking type safety to the next level, take a look at how we use Algebraic Data Types for indestructible payments or how we migrated to Zod 4 for Edge validation.