Type-Safe i18n: Deep Key Extraction in TypeScript
So you’ve been there: you write t('nav.hoem') instead of t('nav.home'), ship it, and a user reports a blank link on production. Runtime error. No warning at build time. Nothing.
The usual approach to i18n treats translation keys as plain strings — which means TypeScript can’t help you. I wanted autocomplete and compile-time errors for every key used on giorgiosaud.io, so I built a type that extracts all valid key paths from the translation object itself.
The DeepKeyOf type
type DeepKeyOf<T> = T extends object
? {
[K in Extract<keyof T, string>]: T[K] extends object
? T[K] extends Array<unknown>
? `${K}`
: `${K}` | `${K}.${DeepKeyOf<T[K]>}`
: `${K}`
}[Extract<keyof T, string>]
: never
Give it a translation object and it returns a union of every valid dot-notation path. Arrays stop the recursion (you probably don’t want items.0.label as a key). Everything else gets flattened into strings like 'nav.home', 'nav.nested.deep', 'footer.copyright'.
The t() function
type NestedKeys = DeepKeyOf<(typeof resources)['en']>
const get = (obj: unknown, path: string, defaultValue = ''): string => {
const keys = path.split('.')
let result: unknown = obj
for (const key of keys) {
if (result == null || typeof result !== 'object') return defaultValue
result = (result as Record<string, unknown>)[key]
}
return result == null ? defaultValue : String(result)
}
export function useTranslations(lang: SupportedLanguages) {
return function t(key: NestedKeys) {
return get(
resources,
`${lang}.${key}`,
get(resources, `${defaultLang}.${key}`, key as string)
)
}
}
The fallback chain is: current language → default language → the key itself. So even if a Spanish translation is missing, you get the English string rather than an empty gap.
Using it in a component:
---
import { getLangFromUrl, useTranslations } from '@i18n/utils'
const lang = getLangFromUrl(Astro.url)
const t = useTranslations(lang)
---
<nav>
<a href="/">{t('nav.home')}</a>
<!-- t('nav.hoem') is now a TypeScript error, not a runtime surprise -->
</nav>
Route translation
UI strings and URL paths are different problems. For routes I keep a separate map:
export const routes = {
notebook: { en: 'notebook', es: 'cuaderno' },
contact: { en: 'contact', es: 'contactame' },
} as const
export type RouteNames = keyof typeof routes
export function useTranslatedPath(lang: SupportedLanguages) {
const translatePath = (path: RouteNames, targetLang: SupportedLanguages = lang, slug?: string) => {
const basePath = routes[path][targetLang]
if (targetLang === defaultLang) {
return slug ? `/${basePath}/${slug}` : `/${basePath}`
}
return slug ? `/${targetLang}/${basePath}/${slug}` : `/${targetLang}/${basePath}`
}
return { translatePath }
}
translatePath('notebook', 'es') gives you /es/cuaderno. RouteNames is inferred from the object so adding a new route automatically adds it to the type — no separate list to maintain.
The initial setup takes an hour or two but after that every typo is caught before it ships. Worth it.




Comments for typsf1