Skip to content

Instantly share code, notes, and snippets.

@daformat
Last active August 28, 2022 10:55
Show Gist options
  • Save daformat/5bf7d9f0409da6cb723b77a724493943 to your computer and use it in GitHub Desktop.
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 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