I have a nested object of translation strings like so:
viewName: {
componentName: {
title: 'translated title'
}
}
I use a translation library that accepts strings in dot notation to get strings, like so translate('viewName.componentName.title')
.
Is there any way I can force the input parameter of translate to follow the shape of the object with typescript?
I can do it for the first level by doing this:
translate(id: keyof typeof languageObject) {
return translate(id)
}
But I would like this typing to be nested so that I can scope my translations like in the example above.
UPDATE for TS4.1. String concatenation can now be represented at the type level through template string types, implemented in microsoft/TypeScript#40336. Now you can take an object and get its dotted paths right in the type system.
Imagine languageObject
is this:
const languageObject = {
viewName: {
componentName: {
title: 'translated title'
}
},
anotherName: "thisString",
somethingElse: {
foo: { bar: { baz: 123, qux: "456" } }
}
}
First we can use recursive conditional types as implemented in microsoft/TypeScript#40002 and variadic tuple types as implemented in microsoft/TypeScript#39094 to turn an object type into a union of tuples of keys corresponding to its string
-valued properties:
type PathsToStringProps<T> = T extends string ? [] : {
[K in Extract<keyof T, string>]: [K, ...PathsToStringProps<T[K]>]
}[Extract<keyof T, string>];
And then we can use template string types to join a tuple of string literals into a dotted path (or any delimiter D
:)
type Join<T extends string[], D extends string> =
T extends [] ? never :
T extends [infer F] ? F :
T extends [infer F, ...infer R] ?
F extends string ?
`${F}${D}${Join<Extract<R, string[]>, D>}` : never : string;
Combining those, we get:
type DottedLanguageObjectStringPaths = Join<PathsToStringProps<typeof languageObject>, ".">
/* type DottedLanguageObjectStringPaths = "anotherName" | "viewName.componentName.title" |
"somethingElse.foo.bar.qux" */
which can then be used inside the signature for translate()
:
declare function translate(dottedString: DottedLanguageObjectStringPaths): string;
And we get the magical behavior I was talking about three years ago:
translate('viewName.componentName.title'); // okay
translate('view.componentName.title'); // error
translate('viewName.component.title'); // error
translate('viewName.componentName'); // error
Amazing!
Playground link to code
Pre-TS4.1 answer:
If you want TypeScript to help you, you have to help TypeScript. It doesn't know anything about the types of concatenated string literals, so that won't work. My suggestion for how to help TypeScript might be more work than you'd like, but it does lead to some fairly decent type safety guarantees:
First, I'm going to assume you have a languageObject
and a translate()
function that knows about it (meaning that languageObject
was presumably used to produce the particular translate()
function). The translate()
function expects a dotted string representing list of keys of nested properties where the last such property is string
-valued.
const languageObject = {
viewName: {
componentName: {
title: 'translated title'
}
}
}
// knows about languageObject somehow
declare function translate(dottedString: string): string;
translate('viewName.componentName.title'); // good
translate('view.componentName.title'); // bad first component
translate('viewName.component.title'); // bad second component
translate('viewName.componentName'); // bad, not a string
Introducing the Translator<T>
class. You create one by giving it an object and a translate()
function for that object, and you call its get()
method in a chain to drill down into the keys. The current value of T
always points to the type of property you've selected via the chain of get()
methods. Finally, you call translate()
when you've reached the string
value you care about.
class Translator<T> {
constructor(public object: T, public translator: (dottedString: string)=>string, public dottedString: string="") {}
get<K extends keyof T>(k: K): Translator<T[K]> {
const prefix = this.dottedString ? this.dottedString+"." : ""
return new Translator(this.object[k], this.translator, prefix+k);
}
// can only call translate() if T is a string
translate(this: Translator<string>): string {
if (typeof this.object !== 'string') {
throw new Error("You are translating something that isn't a string, silly");
}
// now we know that T is string
console.log("Calling translator on \"" + this.dottedString + "\"");
return this.translator(this.dottedString);
}
}
Initialize it with languageObject
and the translate()
function:
const translator = new Translator(languageObject, translate);
And use it. This works, as desired:
const translatedTitle = translator.get("viewName").get("componentName").get("title").translate();
// logs: calling translate() on "viewName.componentName.title"
And these all produce compiler errors, as desired:
const badFirstComponent = translator.get("view").get("componentName").get("title").translate();
const badSecondComponent = translator.get("viewName").get("component").get("title").translate();
const notAString = translator.get("viewName").translate();
Hope that helps. Good luck!
@jcalz 's answer is great.
number
| Date
:You should replace
type PathsToStringProps<T> = T extends string ? [] : {
[K in Extract<keyof T, string>]: [K, ...PathsToStringProps<T[K]>]
}[Extract<keyof T, string>];
with
type PathsToStringProps<T> = T extends (string | number | Date) ? [] : {
[K in keyof T]: [K, ...PathsToStringProps<T[K]>]
}[keyof T];
I made an alternative solution:
type BreakDownObject<O, R = void> = {
[K in keyof O as string]: K extends string
? R extends string
? ObjectDotNotation<O[K], `${R}.${K}`>
: ObjectDotNotation<O[K], K>
: never;
};
type ObjectDotNotation<O, R = void> = O extends string
? R extends string
? R
: never
: BreakDownObject<O, R>[keyof BreakDownObject<O, R>];
Which easily can be modified to also accept uncompleted dot notation strings. In my project we use this to whitelist/blacklist translation object properties.
type BreakDownObject<O, R = void> = {
[K in keyof O as string]: K extends string
? R extends string
// Prefix with dot notation as well
? `${R}.${K}` | ObjectDotNotation<O[K], `${R}.${K}`>
: K | ObjectDotNotation<O[K], K>
: never;
};
Which then can be used like this:
const TranslationObject = {
viewName: {
componentName: {
title: "translated title"
}
}
};
// Original solution
const dotNotation: ObjectDotNotation<typeof TranslationObject> = "viewName.componentName.title"
// Modified solution
const dotNotations: ObjectDotNotation<typeof TranslationObject>[] = [
"viewName",
"viewName.componentName",
"viewName.componentName.title"
];
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With