2

I'm trying to build a translation service. The function that is used to translate tags should provide type checking on possible tags, but also on the replacement object based on the given tag.

I have a tag list object that describes the possible tags and a list of placeholders that should be present inside the translated string.

export const TagList = {
    username_not_found: ['username']
};

The key in this case is the tag name that a dictionary should implement. The value is a list of placeholders that should be present in the translated string.

A dictionary looks somewhat like this:

// Note: The type declaration of keys don't work this way (key should be number or string). Not sure how I should implement this...
const en: {[name: keyof (typeof TagList)]: string} = {
    "username_not_found": "The user {username} does not exist"
}

The method used to translate tags with works like this:

this.trans("username_not_found", {username: "someone@example.com"});

What I'm trying to achieve is type checking (autocompletion) in my IDE for the placeholder object to enforce that all placeholders are configured.

For example:

// This is wrong: "username" placeholder is not specified.
this.trans("username_not_found", {});

// This is also wrong: "foobar" is a non-existing placeholder.
this.trans("username_not_found", {foobar: "42"});

// This is good:
this.trans("username_not_found", {username: "someone@example.com"});

Currently I'm using keyof (typeof TagList) as the argument type for tagName. I'm not sure if this is the correct way of doing this, but it works. I'm now looking for a way to infer the object stucture of the second argument based on the value given in the first argument.

I'm trying to avoid having to maintain multiple lists of possible tags (e.g. having to declare them in both an interface and an object at the same time).

Thanks in advance!

2 Answers2

5

First of all, you need to make TagList immutable.

Then I just created literal type based on key. Very similar to Array.prototype.reduce

export const TagList = {
    username_not_found: ['username'],
    username_found: ['name'],
    batman: ['a', 'b']
} as const;

type Elem = string

type Reducer<
    Arr extends ReadonlyArray<Elem>, // array
    Result extends Record<string, any> = {} // accumulator
    > = Arr extends [] ? Result // if array is empty -> return Result
    : Arr extends readonly [infer H, ...infer Tail] // if array is not empty, do recursive call with array Tail and Result
    ? Tail extends ReadonlyArray<Elem>
    ? H extends Elem
    ? Reducer<Tail, Result & Record<H, string>>
    : never
    : never
    : never;

type TagList = typeof TagList;

const trans = <Tag extends keyof TagList, Props extends Reducer<TagList[Tag]>>(tag: Tag, props: Props) => null as any

trans("username_not_found", { username: "someone@example.com" }); // ok
trans("username_found", { name: "John" }); // ok
trans("batman", { a: "John", b: 'Doe' }); // ok

trans("username_not_found", { name: "someone@example.com" }); // expected error
trans("username_found", { username: "John" }); // expected error

The main goal here is to convert tuple ['username'] into { username: string }

How would you do it in pure js?

['username'].reduce((acc, elem) => ({ ...acc, [elem]: 'string' }), {})

I'm using almost the same algorithm, but recursion instead of iteration.

This is js analogue of Reducer utility type

const reducer = (arr: ReadonlyArray<Elem>, result: Record<string, any> = {}): Record<string, any> => {
    if (arr.length === 0) {
        return result
    }

    const [head, ...tail] = arr;

    return reducer(tail, { ...result, [head]: 'string' })
}

MOre information you can find in my blog

1

I made some demo. Was interested to work with it. Seems it work for me.

const SomeStrings = {
  first: 'some text begin {test} {best}',
  second: ' {nokey} {key} some text in end',
  third: 'gsdfgsdfg',
} as const;

type ExtractReplacedParams<T extends string = string> = T extends `${infer _start}{${infer ks}}${infer _next}`
  ? _next extends `${infer _altStart}{${infer altKey}}${infer _altNext}`
    ? ExtractReplacedParams<_next> & { [k in ks]: string }
    : { [k in ks]: string }
  : Record<string, never>;

type Keys = keyof typeof SomeStrings;

type stMap = { [K in keyof typeof SomeStrings]: typeof SomeStrings[K] };

export const someTranslate = <K extends Keys>(key: K, replaceObj: ExtractReplacedParams<stMap[K]>): string => {
  const phrase = SomeStrings[key];

  if (replaceObj) {
    for (const [replaceKey, value] of Object.entries(replaceObj)) {
      phrase.replace(replaceKey, value);
    }
  }

  return phrase;
};

console.log(someTranslate('second', { nokey: 'seems', key: 'works' }));