一个类型安全的 i18n 玩具

    2989
    最后修改于

    我们知道 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
    总浏览量 4,260最近访客来自 US