我们知道 TS 的类型系统很强大, powerful、flexible,甚至是图灵完备的。可以实现很多传统类型系统中无法实现的类型操作。如此强大的类型系统,很难不被拿来做一些 fancy 的事情,比如:type-challenges、Flappy Bird。本文也是如此(虽然不那么 cool),记录了一个简单的、具有类型提示的 i18n 方法。源于一个周末出于好玩写的小玩具,要简单用上 i18n,但是又不想去查看相关库各种繁琐的配置,索性自己搭了一个。
效果#
啰嗦了那么多,还是看看效果先,最终效果通过调用 translate
函数,传入指定的 key,得到 i18n 结果,通过类型系统使得输入 key 时具有 “类型提示”,避免意料之外的 key。
声明 i18n 信息#
但是在开始动手之前,还是先看看是如何组织 i18n 信息。
虽然其他方案中,有着各种组织 i18n 内容的方案,如按语言分文件夹、按内容模块分 namespace 等(不考虑字典服务),用 json
表示、用 yaml
表示、用 ts
对象表示。此处出于简便起见遵从KISS
原则,直接使用 ts
对象。
通常情况下,我们的 i18n 信息就是一个可以嵌套的键值对,很简单。
// en.ts
const en = {
edit: 'Edit ',
'learn-more': "Click on the Vite and React logos to learn more",
'nested': {
'foo': 'bar'
}
} satisfies Tran
type EnTransSchema = TransSchema<typeof en>
// zh.ts
const zh:EnTransSchema = {
edit: '编辑 ',
'learn-more': "点击 Vite/React logo 了解更多",
'nested': {
'foo': '阿巴阿巴'
}
}
我们可以以这样一个类型去表示。
type Tran = {
[key: string]: Tran | string
}
在 i18n 多语种的情况下,各个语种的 i18n 进度不会完全一致(至少在人工阶段是的,而 AI 肯定会解决这个问题),我们会希望,有一个基本的 i18n 语种,它通常有着一流的 i18n 支持,其他语种的 i18n 要被该语种限制。具体来说:
- 不要有不存在于基本语种中的 key。
- 可以缺失 key,缺失部分回退到基本语种、或者是 key。(Optional)
所以我们需要一个 Schema 类型,从基本 i18n 中推导出 Schema。其他 i18n 语种需要遵循该 Schema。
type TransSchema<T extends Tran> = {
[K in keyof T]?: T[K] extends Tran ? TransSchema<T[K]> : string
}
// en.ts
const en = {
edit: 'Edit ',
'learn-more': "Click on the Vite and React logos to learn more",
'nested': {
'foo': 'bar'
}
} satisfies Tran
type EnTransSchema = TransSchema<typeof en>
// zh.ts
const zh:EnTransSchema = {
edit: '编辑 ',
// this line will report an error
// because not exist in base schema.
'learn-more': "点击 Vite/React logo 了解更多", // error!
// lack of key: `nested`, but it's ok.
}
很简单,对吧,我们可以通过这种方式控制其他语种的 key,避免错误的出现。
添加参数支持#
让我们适度提高一些难度吧。除了键值对中的值除了要处理纯字符串外,在部分 i18n 场景下,可能会需要参数,比如数字的表示(千分位分隔符)、表达习惯的不同让数字位于一句话中不同的位置等。
比如这样:
const en = {
count: (count: number) => `count is ${count}`,
} satisfies Tran
type EnTransSchema = TransSchema<typeof en>
const zh:EnTransSchema = {
count: (count: number) => `计数:${count}`,
}
所以我们需要增强一下原来的类型。
type Tran = {
[key: string]: Tran | TransValueType
}
具体来说,我们希望值的可以是 无参函数、字符串、带参函数。
export type I18nNoArgFn = () => string
export type I18nVArgFn<T extends any[] = []> = (...args: T) => string
// generic to store base schema function parmeters information
export type TransValueType<T extends any[] = any[]>
= string | I18nVArgFn<T> | I18nNoArgFn
对于一个基础的 i18n Schema,我们希望约束其他语种值的类型,在基础 Schema 中的值定义了带参函数时,其他 Schema,应该遵循这个约束,不要使用没有定义的参数。
举例来说就是:
const en = {
count: (count: number) => `count is ${count}`,
} satisfies Tran
type EnTransSchema = TransSchema<typeof en>
const zh:EnTransSchema = {
count: (count: number) => `计数:${count}`,// ok
count: () => `计数:0`,// ok
count: (count: string) => `计数:${count}`,// error!
}
相应的我们需要一个类型基于基础 Schema 的值类型推导出其他 Schema 允许的值类型。
type NoArgValueType = string | I18nNoArgFn
// if string ;result: string | ()=> string
// if () => string ;result: string | ()=> string
// if (p) => string ;result: string | ()=> string | (...params: any) => string
export type GetValueType<T extends TransValueType> = T extends TransValueType<infer _> ?
T extends string ? NoArgValueType :
T extends I18nFn<infer _> ?
Parameters<T> extends Parameters<I18nNoArgFn> ? NoArgValueType :
T extends I18nNoArgFn ? T | NoArgValueType:
T extends I18nVArgFn<infer _> ? T | NoArgValueType : never
: never
: never
值得一提的是,这种方案中我们无法做到区分() => string
和(...param: any[]) => string
。但请忽略这里的缺陷吧,接着向后。
更改了 TransValueType
之后,自然需要将Tran
和 TranSchema
的类型进行更新。
export type Tran = {
[key: string]: Tran | TransValueType
}
type TransSchema<T extends Tran> = {
[K in keyof T]?: T[K] extends Tran ? TransSchema<T[K]> : GetValueType<T[K]>
}
现在我们可以得到一个具有函数约束的 Schema 类型了。
如上所示,输入当参数不符合预期时,会报错。
至此,i18n 信息声明本身的类型安全基本实现了。接下来就是如何调用了。
得到 translate
函数#
从最简单的开始,我们想要 translate
函数能够提示我们 i18n 中所包含的键,首先需要让translate
函数知道我们有着什么样的 key。因此需要一个像下面这样的 createTranslation
函数,将 i18n 的类型信息传递给 translate
。
export const createTrans = (transMap: Record<string, Tran>, defaultLocale: string ) => {
// ... store some useful information
// translate function
return (key: string) => {
const result = '' // get i18n result
return result
}
}
这是我们使用的方式,非常朴素,还不具有类型提示。
关键一步,提取出所有可能的 Key#
加上类型提示的前置要求是,必须提取出所有可能的 key
。
const en = {
edit: 'Edit ',
'learn-more': "Click on the Vite and React logos to learn more",
'nested': {
'foo': 'bar'
}
} satisfies Tran
如果我们将这样的i18n
对象视为一颗树,找到所有的 key
,就是找到所有根结点到叶子节点的路径。对于上面的示例,所有的 key
就是 'edit' | 'learn-more' | 'nested.foo'
。
所有叶子节点和根节点之间的路径,是不是很熟悉?搜索和递归。
我们将根节点的 prefix
记录为''
,每深入一个节点,如果对应的值不是叶节点,就添加到 prefix
上,如果是叶节点,就可以得到根到叶的路径。
type _AllLongestPath<T extends Tran, Prefix extends string> =
{
[K in keyof T]: K extends string ?
T[K] extends Tran ?
//if not leaf, recursive, pass prefix as type parameter
_AllLongestPath<T[K], DotConcat<Prefix, K>> :
// leaf, use prefix to get root-to-leaf path
DotConcat<Prefix, K>
: never
}[keyof T]
export type AllPaths<T extends Tran> = _AllLongestPath<T, ''>
// utility type
export type DotConcat<T extends string, R extends string> =
T extends '' ? R :
T extends '' ? R : `${T}.${R}`
一个等效的函数实现如下:
const isTranValue = (obj: Tran | TransValueType) =>
typeof obj === 'string' || typeof obj === 'function'
const dotConcat = (prefix: string, str: string) => prefix && str ? `${prefix}.${str}` : str
function _AllLongestPath(obj: Tran, prefix: string = ''): string[] {
const p = []
for (const key in obj) {
const curPrefix = dotConcat(prefix, key)
let v = [curPrefix]
if (!isTranValue(obj[key])) {
v = _AllLongestPath(obj[key], curPrefix)
}
p. push(...v)
}
return p;
}
至此,我们得到了所有所有可能的 key
类型。
让 translate 函数带有类型提示#
得到了所有的 key 类型,再完善 translate 函数就很简单了,不是吗?
export const createTrans =
<
M extends Record<string, Tran>,
DefaultLocale extends MustString<keyof M>,
T extends M[DefaultLocale] = M[DefaultLocale]
>(transMap: M, defaultPath: DefaultLocale) => {
// ... store some useful information
// translate function
return <P extends AllPaths<T>>(key: P) => {
const result = '' // get i18n result
return result
}
}
至此,我们就有了一个可以对所有 key
进行类型提示的 translate
函数。
进一步完善 translate
函数,柯里化#
结合实际场景看,以前端 react 的 useHook 风格的 i18n 为例。
在一个组件中,我们使用 useTrans
这样的函数获取一个 translate
函数,随后通过 key
二次调用得到 i18n 结果。在这个过程中,我们在使用 useTrans
时,可以传入一个前缀,得到一个新的translate
函数,然后使用新的函数实现目的。
function Component() {
const {t, locale} = useTrans('some-prefix')
return <div>{t('label.qr-find-me')}</div>
}
更一般的说,我们希望translate
函数是 “柯里化” 的。
export const createTrans =
<
M extends Record<string, Tran>,
DefaultLocale extends MustString<keyof M>,
T extends M[DefaultLocale] = M[DefaultLocale]
>(transMap: M, defaultPath: DefaultLocale) => {
// ... store some useful information
// translate function
const p = <P extends AllPossiblePaths<T>>(key: P) => {
if(key reach leaf) {
const result = '' // get i18n result
return result
}else if (not leaf){
return <AllRestKey>p
}
}
return p
}
所以,类型提示的时候除了提示完整的 Key
之外,还要提供所有前缀提示,以及对应的剩余后缀提示。
// utils types
export type DotConcat<T extends string, R extends string> =
T extends '' ? R :
T extends '' ? R : `${T}.${R}`
export type _Prefix<T extends string> = T extends `${infer P}.${infer Rest}`
? P | DotConcat<P, _Prefix<Rest>> : never
export type Prefix<T extends string> = _Prefix<T> | ''
export type AllPossibleRestKey<T extends string, Prefix extends string = ''>
= Prefix extends '' ?
T : T extends `${Prefix}.${infer Rest}` ? Rest
: never
type Keys = 'abc.test' | 'de' | 'cdse.test.d'
type P = AllPrefix<Keys>
// expect 'abc' | 'cdse' | 'cdse.test' | ''
type R = AllPossibleRestKey<Keys, 'abc'>
// expect 'test'
这就是了。我们制造了一些工具类型,获取所有的前缀、后缀类型。
下一步就是完善柯里化的函数了。
我们先定义这个函数的结果类型。显然,在这种柯里化的情况下,对于该函数,我们需要通过已存储的Prefix
和当前当前的Key
来判断结果类型。
具体来说,如果 ${Prefix}.${Key}
是一条到叶节点的路径,就返回 string
,如果还不是叶节点,那么就返回一个函数。
type Result<
T extends Tran,
// 已存储 Prefix
Prefix extends AllPrefix<ALLPATH>,
// 输入的 Key
K extends AllPrefix<Rest> | Rest,
// 所有可能到叶节点的路径
ALLPATH extends AllPaths<T> = AllPaths<T>,
// 移除Prefix之后,剩余的所有路径,用来约束返回函数的 Key 类型
Rest extends AllPossibleRestKey<ALLPATH, Prefix>
= AllPossibleRestKey<ALLPATH, Prefix>,
ResultPrefix extends DotConcat<Prefix, K> = DotConcat<Prefix, K>,
ResultRest extends AllPossibleRestKey<ALLPATH, ResultPrefix> =
AllPossibleRestKey<ALLPATH, ResultPrefix>
> =
// 是一条到叶节点的路径,也就意味着 RestKey 只能是 '',返回 string
AllPossibleRestKey<Rest, K> extends '' ? string :
// 不是到叶节点的的路径
ResultPrefix extends AllPrefix<ALLPATH> ?
// 约束函数输入参数,输入的 PK extends AllPrefix<ResultRest> | ResultRest
<PK extends AllPrefix<ResultRest> | ResultRest>(key: PK)
// 约束输出结果将
// ResultPrefix -> 替换 Prefix
// ResultRest -> 替换 Rest
=> Result<T, ResultPrefix, PK, ALLPATH, ResultRest>
: never
于是 createTrans
函数就变形为这样。
export const createTrans =
<
M extends Record<string, Tran>,
DefaultLocale extends MustString<keyof M>,
T extends M[DefaultLocale] = M[DefaultLocale]
>(transMap: M, defaultPath: DefaultLocale) => {
// ... store some useful information
// translate function
return <
K extends Exclude<AllPrefix<AllPaths<T>>, ''>,
F extends K | AllPaths<T>
>(key: F):Result<T, '', F> => {
let memorizedKeyPath = ''
if(isFullPath(key)) {
return getResult(key) as Result<T, '', F>
}else { memorizedKeyPath = key }
const fn= <
PK extends Exclude<AllRestPath | AllPrefix<AllRestPath>, ''>,
Prefix extends AllPrefix<ALLPATH> = K,
ALLPATH extends AllPaths<T> = AllPaths<T>,
AllRestPath extends AllPossibleRestKey<ALLPATH, Prefix> = AllPossibleRestKey<ALLPATH, Prefix>
>(key: PK): Result<T, Prefix, PK> => {
let curPath = memorizedKeyPath
if(curPath!='' && key != '') {
curPath = `${curPath}.${key}`
memorizedKeyPath = curPath
}
if(isFullPath(curPath)) {
// string
return getResult(curPath) as Result<T, Prefix, PK>
}
// fn
return fn as Result<T, Prefix, PK>
}
return fn as Result<T, '', F>
}
}
通过memorizedKeyPath
记录前缀,结合当前 key
判断返回结果,至此就有了一个柯里化的函数。
当然,实际情况下多半不需要要这么复杂的柯里化,在多层嵌套的情况下,这会导致类型提示总是会提示不完整的 path,增加心智负担。因此多数情况下,只需要两层,第一层可以接受 Prefix,第二层则只接受剩余的完整路径。
至此,主体部分就完工了。不过还有柯里化之后 translate
函数的传参没做,这里就略过了。
结束语#
哈,结束了。到目前为止,都还很简单,不是吗😛?所以我管它叫sts-i18n
。(simple-typesafe-i18n
)。做完之后,我发现这很适合 TS
初学者去深入熟悉 TS
类型系统,相比于直接刷题的type challenge
,也许这样一步步做一个玩具会更有意思?
当然,玩具的问题就在于,太简单了,使用场景单一。如前文所述,各种表示方式、i18n
工具的适配(如 crowdin)、namespace、文件夹的处理都没考虑。
但是,管他呢,just for fun
😆。