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.
Why you need DeepPath
-
Real Validation:
"api.endpionts.users"stops compiling automatically. - Full path autocompletion: The IDE natively suggests all possible combinations.
- Maintenance: If you change a key in the config, the compiler breaks wherever it's used.
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 DeepPath<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${K}` | `${K}.${DeepPath<T[K]>}`
: `${K}`;
}[keyof T & string]
: never;
// Usage example:
interface AppConfig {
api: {
endpoints: {
users: string;
posts: string;
};
timeout: number;
};
theme: "dark" | "light";
}
type ConfigPaths = DeepPath<AppConfig>;
// Result: "api" | "theme" | "api.endpoints" | "api.timeout" | "api.endpoints.users" | "api.endpoints.posts"
Developer Evolution: Using satisfies
I often see configurations annotated manually (const config: AppConfig = ...). This "flattens" the type and erases the specific inference of your actual data.
For DeepPath<T> to work perfectly, it needs to know the exact literal structure. That's where the satisfies operator comes in: it validates that you meet the interface but keeps the literals intact.
// The "Wizard" Path
const config = {
api: {
endpoints: {
users: "/users",
posts: "/posts"
},
timeout: 5000
},
theme: "dark"
} satisfies AppConfig;
// 'config.theme' remains as the literal "dark"
// and we can navigate any path with full safety:
function getProp(path: DeepPath<AppConfig>) {
// implementation logic...
}
💥 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 DeepValue<T, P> = P extends `${infer K}.${infer Rest}`
? K extends keyof T
? DeepValue<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
// Pure magic:
function getConfig<P extends DeepPath<AppConfig>>(path: P): DeepValue<AppConfig, P> {
// implementation (e.g., using lodash/get)
return {} as any;
}
const url = getConfig("api.endpoints.users"); // inferred as string
const timeout = getConfig("api.timeout"); // inferred as number
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.
| Pros | Cons |
|---|---|
| Zero typo-related runtime errors. | Compiler performance: generates massive unions that can slow down the IDE. |
| 100% safe and traceable refactoring. | Recursion limits: TS might halt if the object is excessively deep. |
| Native autocompletion without extra plugins. | Complexity: Dynamic arrays make inference much harder. |
Conclusion
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, check out my other posts on campa.dev.
Top comments (0)