Last active
August 28, 2022 10:55
-
-
Save daformat/5bf7d9f0409da6cb723b77a724493943 to your computer and use it in GitHub Desktop.
Foundation for a TS typed translation helper with autocomplete, intellisense, and type-checking
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ----------------------------------------------------- | |
// This is the foundation for a typed translation helper | |
// with autocomplete, intellisense, and type-checking | |
// ----------------------------------------------------- | |
// TS playground link: | |
// https://www.typescriptlang.org/play?#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-T1BDNrhMzZ8zoXkc4GR2ZpW8ZuDGDwHZ4Hjn-POfPW5mr0EvOpgp2GUQlACDU4k7T0H9Pcc2dMDBdkNlAxYGU9wbnUW6eDhyx1rQPW+taBy-F7KSWww476ul4HnmDDcOK+e0reWQvQUwAAC+UyVlwDXKvVYK-VirVXUwtdHM+wbV0RugDG6tCbSspszf5rsBbhDODLaG6AT3G2yBbdoDtm4YAmMmJILEXgdAiWURRgLXBdzJCXoulZXYsYWnSFtBYxQlhrC2BiA4VyZALM4bLGshch28METlE37DR2TsBFSFGS2ZBNxXXr4aTcc2Q1GhE+4Hg9oMiMBJMYUA0g-DUEYCGy9+IhZTh8JXuwNfPct7LIqNhLf8PsFh0PhvqAWlybz9m6DEfjDiFStvyju+Yjx-ujpcbFAyFKyQJHxvBEMFCFKdWQQoFvdYFDBTNAcDcXWyfTRWJ4BTF-GwN-RwN+bQLoWyUwJwEQTMA4LwaUKAKAtDUgsgkID-XGA+VcT3XDQgEYRvdpC7Tpf-fbUAOguYRg6CB7AwA3MdJTE3AAWWYDgnvVYNqXYMIHGC4IB2owVBkzkyBx5zxBggABZxg5CBCYJhDCA9kxDaDCAhguCsdOhgxmsLBFRfNecYINCTR1gDDVDjD7BqB2hVdEsaIwx2FFpgcWDMNeYSB2h3hChFQ6CRgLAODwipDIihhIjHCxCAhOZ94BNxCsNcMsAGCJRCAmDKNKA5D5R9C2C0jODMjsibBciLCPllNiACQYJiAbECB5Z-VSBUQ9D7DCisBpCSjuDmNeC8j+wcBeDqi-BajpsJY8BGjdIWikACiJC0ijCuieDLcLCZjUisBHCujZCKjFCrCVD5J1DWiUijQ0i+gnC7ZTD3M8iI8addjpCVijisAAA2U4lwtw1EDwnAPI+I1AGA9bBEa4O6f0TIhEFGFvWvSmMsclBiDvFvPsVAGkY+PyQ4ieYo+SRY4rcPEbME3MFEjVJlEwCNKxXGT6AsDvUCOYCwaEnDf8MEjyMsNVf+BlQBLVckjdflSROYIVB2J2MVQUaku6V6FEmVUJIkr6Uk2cFkykw0PsO6S0FE+k3E0+dkiwWsOYaUyiFUssQ1TWduU1FkjyWE1QP+TrBE0+JE0CTo+SZgjE-bGki0+UxkzVSRcYZUtk3McYTkkVZ2fiNUtkN0zUh0vEx6FlHGNlEk4Ehcc0ik8MzvXsH0sgV6C0oU7GEUsMoQFGSMvYEEqksEy0O0v+BUoBZ0jdcYOM4szUrmI1LWBMruEspAIAA | |
// Based on this [detailed answer](https://stackoverflow.com/a/58308279/1358317) | |
// on how to type check tranlation helpers | |
// --- translation types and logic --- | |
// ----------------------------------- | |
/** | |
* Base translation type, we're working with strings or objects with strings | |
*/ | |
type StringOrStringObject = string | { [x: string]: StringOrStringObject } | |
/** | |
* Any valid path for a given object O properties, including nested properties | |
*/ | |
type ObjectPath<O> = O extends Record<string, StringOrStringObject> | |
? { | |
[K in keyof O]-?: | |
| `${K & string}` | |
| `${ConcatToPath<K & string, ObjectPath<O[K]>>}` | |
}[keyof O] | |
: "" | |
/** | |
* Build path string out of the given key strings K & P | |
*/ | |
type ConcatToPath<K extends string, P extends string> = `${K}${"" extends P | |
? "" | |
: "."}${P}` | |
/** | |
* Get the given string S translation params as tuple {key: string} | |
*/ | |
type I18NParams<S> = S extends string | |
? S extends `${string}{{${infer B}}}${infer C}` | |
? C extends `${string}{{${string}}}${string}` | |
? [B, ...I18NParams<C>] | |
: [B] | |
: never | |
: never | |
/** | |
* Access the property at path K and return its value as a type | |
*/ | |
type GetPropertyValue<K extends string, O> = K extends `${infer A}.${infer B}` | |
? A extends keyof O | |
? GetPropertyValue<B, O[A]> | |
: never | |
: K extends keyof O | |
? O[K] | |
: never | |
/** | |
* Interpolate the translation result in template string notation | |
*/ | |
type Interpolate< | |
S, | |
I extends Record<I18NParams<S>[number], string> | |
> = S extends string | |
? S extends "" | |
? "" | |
: S extends `${infer A}{{${infer B}}}${infer C}` | |
? C extends `${string}{{${string}}}${string}` | |
? `${A}\${${Extract<B, keyof I>}}${Interpolate<C, I>}` | |
: `${A}\${${Extract<B, keyof I>}}${C}` | |
: `${S}` | |
: never | |
/** | |
* Return F if P is valid I18N params, T otherwise (undefined by default) | |
*/ | |
type WithI18NParams<P, F = P, T = undefined> = P extends string ? T : F | |
/** | |
* Utility to make a function argument optional using `...arg: OptionalArg` | |
*/ | |
type OptionalArg<P, T, F = undefined> = P extends string ? [T] : [F] | |
/** | |
* Get the given object O nested property at path K | |
* @param obj | |
* @param path | |
*/ | |
function getNestedProperty< | |
O extends Record<string, StringOrStringObject>, | |
K extends ObjectPath<O> | |
>(obj: O, path: K) { | |
const keys = path.split(".") | |
return keys.reduce( | |
(val: StringOrStringObject, key) => | |
typeof val === "string" ? val : val[key], | |
obj | |
) | |
} | |
/** | |
* Generate translation function for the given source object | |
* @param obj | |
*/ | |
function typedTranslation< | |
O extends Record<string, StringOrStringObject> | |
>(obj: O) { | |
return function t< | |
K extends ObjectPath<O>, | |
P extends I18NParams<GetPropertyValue<K, O>>, | |
I extends Record<P[number], string>, | |
V extends GetPropertyValue<K, O>, | |
A extends WithI18NParams<P, R>, | |
R extends Interpolate<V, I> | |
>(k: K, ...args: OptionalArg<A, I>): R { | |
let translation = getNestedProperty(obj, k) as string | |
if (args) { | |
Object.values<{ string: string }>(args).forEach((entry) => | |
Object.entries(entry).forEach(([key, value]) => { | |
translation = translation.replace( | |
new RegExp(`{{${key}}}`, "g"), | |
value | |
) | |
}) | |
) | |
} | |
return translation as unknown as R | |
} | |
} | |
// --- Test data for the playground --- | |
// ------------------------------------ | |
const dict_en = { | |
hello: "Hello", | |
welcome: "Welcome {{name}}", | |
unread: "{{unread_count_str}} unread - {{name}}’s inbox", | |
menu: { | |
deselect: "Deselect {{count_str}}", | |
copy: "Copy {{count_str}}", | |
delete: "Delete {{count_str}}?", | |
preferences: { | |
account: "Account", | |
theme: "Theme", | |
typography: "Typography" | |
}, | |
logout: "Logout" | |
} | |
} as const // the translations have to be marked as const to be readonly | |
const dict_fr = { | |
hello: "Bonjour", | |
welcome: "{{name}}, bienvenue", | |
unread: "{{unread_count_str}} non lu - {{name}} - Boite de réception", | |
menu: { | |
deselect: "Désélectionner les {{count_str}}", | |
copy: "Copier les trois {{count_str}}", | |
delete: "Supprimer {{count_str}} ?", | |
logout: "Logout", | |
preferences: { | |
account: "Compte", | |
theme: "Thème", | |
typography: "Typographie" | |
} | |
} | |
} as const // the translations have to be marked as const to be readonly | |
// We need a union type of the literal values to display proper intellisense | |
type Dict = typeof dict_en | typeof dict_fr | |
// declare `as Dict` to get each available language variation in intellisense | |
const dict = { ...dict_en } as Dict // spread values for cleaner intellisense | |
// Get the t function | |
const t = typedTranslation(dict) | |
// --- Test implementation - intellisense, autocomplete and typechecking --- | |
// ------------------------------------------------------------------------- | |
// Valid: | |
const _t0 = t("hello") | |
const _t1 = t("welcome", { | |
name: "Mat" | |
}) | |
const _t2 = t("unread", { unread_count_str: "42", name: "Mat" }) | |
const _t3 = t("menu.copy", { count_str: "2" }) | |
const _t4 = t("menu.preferences.account") | |
console.log({ _t0, _t1, _t2, _t3, _t4 }) | |
// Invalid: | |
const _i0 = t("hello", {}) | |
const _i1 = t("hello", { something: "that is not there" }) | |
const _i2 = t("welcome", { | |
something: "that is not there" | |
}) | |
const _i3 = t("welcome", {}) | |
const _i4 = t("unread", { read_count_str: "42" }) | |
const _i5 = t("menu.copy", { count_str: 2 }) | |
const _i6 = t("menu.preferences", {}) | |
// Test types | |
type O = typeof dict | |
type K = ObjectPath<typeof dict> | |
// with params | |
const key1 = "welcome" as const | |
type P1 = I18NParams<GetPropertyValue<typeof key1, typeof dict>> | |
type I1 = WithI18NParams<P1, Record<P1[number], string>> | |
type V1 = GetPropertyValue<typeof key1, typeof dict> | |
type A1 = WithI18NParams<P1, R1> | |
type R1 = Interpolate<V1, I1> | |
// without params | |
const key2 = "hello" as const | |
type I2 = WithI18NParams<P2, Record<P2[number], string>> | |
type P2 = I18NParams<GetPropertyValue<typeof key2, typeof dict>> | |
type V2 = GetPropertyValue<typeof key2, typeof dict> | |
type A2 = WithI18NParams<P2, R2> | |
type R2 = Interpolate<V2, I2> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment