The Problem 😤
We've all been there. You're working with a deeply nested object in TypeScript:
const user = await fetchUser();
const city = user?.profile?.address?.city; // 🤮
And then you realize:
- No autocompletion for paths
- Runtime errors when something is undefined
- The ?. chain is getting ridiculous
- You lose type safety with dynamic paths
The Solution ✨
I built ts-safe-path - a tiny (< 2KB) TypeScript utility that gives you:
import { safePath } from 'ts-safe-path';
const sp = safePath(user);
const city = sp.get('profile.address.city'); // 🎉 Full autocompletion!
Mind-blowing Features 🤯
1. Autocompletion for ALL nested paths
const data = {
user: {
settings: {
theme: 'dark',
notifications: {
email: true,
push: false
}
}
}
};
const sp = safePath(data);
// As you type, IDE suggests:
// 'user'
// 'user.settings'
// 'user.settings.theme'
// 'user.settings.notifications'
// 'user.settings.notifications.email'
// etc...
2. Type-safe operations
// ✅ This compiles
sp.set('user.settings.theme', 'light');
// ❌ This doesn't compile
sp.set('user.settings.theme', 123); // Error: Type 'number' is not assignable to type 'string'
// ❌ This doesn't compile either
sp.get('user.settings.invalid'); // Error: Argument of type '"user.settings.invalid"' is not assignable
3. Clean API
const sp = safePath(data);
// Get values
const theme = sp.get('user.settings.theme'); // 'dark'
// Set values (creates intermediate objects if needed!)
sp.set('user.profile.avatar.url', 'https://...');
// Check existence
if (sp.has('user.settings.notifications.email')) {
// ...
}
// Update with a function
sp.update('user.stats.loginCount', count => (count || 0) + 1);
// Deep merge
sp.merge({
user: {
settings: {
language: 'fr'
}
}
});
Real-world Example: React Forms
Before ts-safe-path:
const handleChange = (field: string, value: any) => {
setFormData(prev => {
const newData = { ...prev };
// Ugly nested assignment
if (field === 'user.address.city') {
newData.user = {
...newData.user,
address: {
...newData.user.address,
city: value
}
};
}
// ... imagine doing this for 20 fields 😱
return newData;
});
};
With ts-safe-path:
const handleChange = (field: string, value: any) => {
setFormData(prev => {
const newData = { ...prev };
safePath(newData).set(field as any, value); // ✨ One line!
return newData;
});
};
The Technical Magic 🪄
The secret sauce is TypeScript's template literal types:
type PathKeys<T> = T extends Record<string, any>
? {
[K in keyof T]: K extends string
? T[K] extends Record<string, any>
? K | `${K}.${PathKeys<T[K]>}`
: K
: never;
}[keyof T]
: never;
This recursively generates all possible paths as a union type!
Performance
- Zero dependencies
- < 2KB gzipped
- No runtime overhead - it's just property access under the hood
- Tree-shakeable
Try it out!
npm install ts-safe-path
pnpm add ts-safe-path
yarn add ts-safe-path
What's Next?
I'm planning to add:
- Array path support (users[0].name)
- Path validation at runtime
- React hooks integration
- Vue 3 composables
If this solved a problem for you, give it a ⭐ on GitHub!
What do you think? Have you faced similar issues with nested objects in TypeScript?
Top comments (3)
github link not working
Sorry 🥲
Now is working 😇
EDIT
This guy rang a bell, and now I remember:
dot-prop
This is essentially what
dot-prop
does, only with different syntax. The one advantage is the inference of the keys.Type inference on the result seems incorrect, though. You might want to check. See this playground. See the type of
t
. Yours, variablex
assumes it can beundefined
but that's incorrect.dot-prop
is doing it correctly.