19

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.

Jakob
  • 543
  • 2
  • 5
  • 10

4 Answers4

49

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
  • 264,269
  • 27
  • 359
  • 360
  • Impressive solution! Even though the syntax becomes a bit more complex, it does the job. – Jakob Nov 02 '17 at 21:42
  • Updated to mention template string types slated to come out in TS4.1 – jcalz Sep 03 '20 at 01:59
  • @jcalz thanks a lot for the post 4.1 update, been waiting for typed dotted paths for quite a while. Could you also provide an example for a generic dotted path type like `DottedPath`? – Dominic Sep 21 '20 at 14:09
  • 1
    It depends on exactly what you're looking for; all paths to all properties even in a recursive tree-like object? excluding paths with non-dottable properties? It's hard to figure this out in a comment section of a different question. You might want something like [this](https://tsplay.dev/RwRlxm) but I'm not sure. Or you might want to just ask a new question. Good luck! – jcalz Sep 21 '20 at 16:12
  • Thanks a lot. The code you provided is basically what I was looking for. The use case for which I've been looking for typed dotted path is for a querybuilder for Firestore. To type something like `qb.where('name.username', '==', 'jcalz')` – Dominic Sep 22 '20 at 08:31
  • Great solution! With TypeScript 4.2 the Join type needs to be adjusted (sorry for lack of formatting in comment): type Join = T extends [] ? never : T extends [infer F] ? F : T extends [infer F, ...infer R] ? F extends string ? string extends F ? string : R extends string[] ? `${F}${D}${Join}` : never : never : string; – Bjørn Egil Nov 25 '20 at 10:27
  • 1
    @BjørnEgil thanks; I modified my code above to use `Extract`. Looks like the release version of 4.1 also needs this. – jcalz Nov 25 '20 at 16:48
  • @jcalz, it should be versoin 4.1 – Bjørn Egil Nov 29 '20 at 18:19
  • F extends string ? string extends F ? string Could you help me explain why we need to check "string extends F". I think F extends string ? `${F}${D}${Join, D>}` : never : string; still works – Nghi Nguyen Jan 16 '21 at 13:23
  • 1
    When I first worked on that, (when TS4.1 was still in development) I think there were no types like `\`${string}.${string}\`` (i.e., before [microsoft/TypeScript#40598](https://github.com/microsoft/TypeScript/pull/40598)) , so `Join<[string, string], '.'>` would have produced just `string` anyway... my code bailed out early rather than recurse for the same result. It's fine to make the change you're suggesting. – jcalz Jan 16 '21 at 15:38
  • @jcalz is it possible to make it generic like DottedLanguageObjectStringPaths? If I do this it compains that it can be infinitely deep. – MistyK Feb 12 '21 at 08:03
  • Is there a way to extend this to allow a `Plurals` object to be defined on the `languageObject`? Those would have the format: `pluralKey: { one: '1 day', other: '{count} days' }`. In this case it should only resolve as `pluralKey` and omit any children such as `one` and `other`. – LordOfBones Mar 30 '21 at 19:18
  • @LordOfBones probably, but that would seem to be out of scope for this question. – jcalz Mar 30 '21 at 19:20
  • You deserve Stackoverflow gold for this! Thanks! – aelesia Jun 04 '21 at 18:59
  • Great solution! Can it be modified to accept functions as object leaves? E.g: ```templatedString: (amount: number) => "Amount" + amount``` – user5480949 Dec 21 '21 at 13:34
  • 1
    @user5480949 Like [this](https://tsplay.dev/wX25Om) maybe? Comments sections on old answers are not the ideal place to get followup answers, so if you continue to have questions you might want to make your own post. – jcalz Dec 21 '21 at 15:51
  • @jcalz As always: I'm very grateful for your relentless support! – user5480949 Dec 21 '21 at 19:15
  • @jcalz As soon as I add a generic into the mix, I start getting issues with the compiler saying the type depth is too large. `type DottedLanguageObjectPrimitiveOrFunctionPaths = Join, ".">` `declare function translate(dottedString: DottedLanguageObjectPrimitiveOrFunctionPaths): string;` Any idea how to resolve this? This is exactly what I'm looking for for my project, but I need to be able to pass in interfaces dynamically – Alex Plumb Apr 11 '22 at 17:28
  • @AlexPlumb In [this other answer](https://stackoverflow.com/a/58436959/2887218) I note that these sort of deep-recursive types tend to make the compiler unhappy if modified slightly. You might be able to avoid this problem with some changes, but comments sections on old answers are not the ideal place to discuss this. If you need something specific solved you might want to make your own post, mentioning how this solution doesn't quite work for your use case. – jcalz Apr 11 '22 at 17:34
8

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"
];
thomaaam
  • 91
  • 1
  • 4
3

@jcalz 's answer is great.

If you want to add other types like 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];
ApplY
  • 31
  • 3
0

If the aim it to provide auto-completion, the only way I could think of providing this would be to create a type to limit what strings are allowable:

type LanguageMap = 'viewName.componentName.title' | 'viewName.componentName.hint';

function translate(id: LanguageMap) {
    return translate(id)
}

You wouldn't be able to automatically generate this using your keyof trick as the nesting would prevent that.

An alternate would be to remove the nesting, in which case your keyof trick creates the language map type for you:

let languageObject = {
    'viewName.componentName.title': 'translated title',
    'viewName.componentName.hint': 'translated hint'
};

function translate(id: keyof typeof languageObject) {
    return translate(id)
}

But I know of no way to get the best of both worlds as there is a logical break between the nesting on the one hand, and the key names on the other.

Fenton
  • 241,084
  • 71
  • 387
  • 401
  • Yes, a flat object is what I'm using succesfully today. The nested translation object was something I was reaching for to improve the structure of my translations file. However, listing every possible path is not a quialified solution – Jakob Nov 02 '17 at 21:39