一个类型安全的 i18n 玩具

2987
最后修改于

我们知道 TS 的类型系统很强大, powerful、flexible,甚至是图灵完备的。可以实现很多传统类型系统中无法实现的类型操作。如此强大的类型系统,很难不被拿来做一些 fancy 的事情,比如:type-challengesFlappy 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 要被该语种限制。具体来说:

  1. 不要有不存在于基本语种中的 key。
  2. 可以缺失 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 之后,自然需要将TranTranSchema的类型进行更新。

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 😆。

  • 🥳0
  • 👍0
  • 💩0
  • 🤩0