打字稿字符串自动完成对象结构中途

Typescript string autocomplete object structure midway

我正在尝试为 next-intl 包中的函数 useTranslations 添加类型支持。

让我们假设我的 ./locales/en.ts 文件看起来像这样

const dict = {
  one: {
    two: {
      three: "3",
      foo: "bar"
    }
  }
}
export default dict

useTranslations 函数接受命名空间的可选参数。它 returns 另一个函数,调用时具有必需的命名空间参数。通过设置可选参数,它减少了将命名空间的那部分应用到所需参数的需要。这是一个例子:

const t1 = useTranslations()    // no optional arg
const val1 = t("one.two.three") // 3
const val2 = t("one.two.foo")   // bar

const t2 = useTranslations("one.two")
const val3 = t("three") // 3
const val4 = t("foo")   // bar

我已经设法让类型工作以创建对象的字符串表示形式,但我在第一个参数继续到第二个时遇到问题。

到目前为止,这是我的代码

import dict from "./locales/en"

// `T` is the dictionary, `S` is the next string part of the object property path
// If `S` does not match dict shape, return its next expected properties
type DeepKeys<T, S extends string> = T extends object
  ? S extends `${infer I1}.${infer I2}`
    ? I1 extends keyof T
      ? `${I1}.${DeepKeys<T[I1], I2>}`
      : keyof T & string
    : S extends keyof T
    ? `${S}`
    : keyof T & string
  : ""

interface UseTranslationsReturn<D> {
  <S extends string>(
    namespace: DeepKeys<D, S>,
    values?: Record<
      string,
      | string
      | number
      | boolean
      | Date
      | ((children: ReactNode) => ReactNode)
      | null
      | undefined
    >
  ): string
}

interface UseTranslationsProps<D = typeof dict> {
  <S extends string>(namespace?: DeepKeys<D, S>): UseTranslationsReturn<D>
}

export const useTranslations: UseTranslationsProps = (namespace) => useNextTranslations(namespace)

我可以像这样设置主要功能,

const t = useTranslations("one.two")

但是我得到一个错误,

const val = t("three") // Argument of type '"three"' is not assignable to parameter of type "one"

如何调整类型来解决这个问题?

首先,useTranslations 应该期望有效的路径点符号。我的意思是它应该期望 "one" | "one.two" | "one.two.three" | "one.two.foo" 的并集。你会在评论中找到解释:

const dict = {
  one: {
    two: {
      three: "3",
      foo: "bar"
    }
  }
} as const

type Dict = typeof dict

type KeysUnion<T, Cache extends string = ''> =
  /**
   * If T is no more an object, it means that this call
   * is last in a recursion, hence we need to return
   * our Cache (accumulator)
   */
  (T extends PropertyKey
    ? Cache
    /**
     * If T is an object, iterate through each key
     */
    : { [P in keyof T]:
      (P extends string
      /**
       * If Cache is an empty string, it means that this call is first,
       * because default value of Cache is empty string
       */
        ? (Cache extends ''
        /**
         * Since Cache is still empty, no need to use it now
         * Just call KeysUnion recursively with first property as an argument for Cache
         */
          ? KeysUnion<T[P], `${P}`>
          /**
           * Since Cache is not empty, we need to unionize it with property 
           * and call KeysUnion recursively again,
           * In such way every iteration we apply to Cache new property with dot
           */
          : Cache | KeysUnion<T[P], `${Cache}.${P}`>)
        : never)
    }[keyof T])

// type Result = "one" | "one.two" | "one.two.three" | "one.two.foo"
type Result = KeysUnion<Dict>

Playground

您可以找到相关答案:[ , here, , here] and in my article

现在您知道您的命名空间是安全的。我们需要确保我们的第二个(curried)函数需要有效的前缀。我的意思是,如果您提供了 one.two 命名空间,您期望后缀为 threefoo.

为此,我们需要以某种方式从 "one" | "one.two" | "one.two.three" | "one.two.foo" 中提取所有包含 one.two 的键。此外,我们应该摆脱获得的密钥 one.two 前缀,只保留尾部。

我们可以使用这个助手来提取后缀:


type ExtractString<
  T extends string,
  U extends string,
  Result extends string = ''
  > =
  // infer first char
  T extends `${infer Head}${infer Tail}`
  /**
   * check if concatenated Result and infered char (Head)
   * equal to second argument
   */
  ? `${Result}${Head}` extends U
  /**
   * if yes - return Tail and close recursion
   */
  ? Tail
  /**
   * otherwise, call recursion with Tail (without first char)
   * and updated Result
   */
  : ExtractString<Tail, U, `${Result}${Head}`>
  : Result

/**
 * 1) o === one.two ? ExtractString<ne.two.three, 'one.two','o'>
 * 2) on === one.two ? ExtractString<e.two.three, 'one.two','on'>
 * 2) one === one.two ? ExtractString<.two.three, 'one.two','one'>
 * .....
 */
type Test = ExtractString<'one.two.three', 'one.two'>

Playground

好的,我们有有效的后缀,但我们需要去掉前导点 .three:


type RemoveDot<T extends string> = T extends `.${infer Tail}` ? Tail : T;

// three
type Test = RemoveDot<'.three'>

Playground

请不要忘记我们需要决定我们需要使用哪些键 ExtractString。因为我们只需要从提供的命名空间而不是所有键中提取前缀。

type ValidPrefix<
  T extends string,
  U extends string
  > = T extends `${U}${string}` ? Exclude<T, U> : never


// "one.two.three" | "one.two.foo"
type Test = ValidPrefix<'one.two.three' | 'one' | 'one.two.foo', 'one.two'>

Playground

我们差不多有自己的类型了。还剩下一件事。我们需要推断我们的 return 类型。


type Values<T> = T[keyof T]

type Elem = string;

type Acc = Record<string, any>

type Predicate<Accumulator extends Acc, El extends Elem> =
  El extends keyof Accumulator ? Accumulator[El] : Accumulator

type Reducer<
  Keys extends Elem,
  Accumulator extends Acc = {}
  > =
  Keys extends `${infer Prop}.${infer Rest}`
  ? Reducer<Rest, Predicate<Accumulator, Prop>>
  : Keys extends `${infer Last}`
  ? Predicate<Accumulator, Last>
  : never

// 42
type Test = Reducer<'one.two', {
  one: {
    two: 42
  }
}>

Playground

你用纯js的例子解释了reducer。 您也可以在我的 article

中找到解释

既然我们已经有了所有需要的工具,我们可以输入我们的函数:

const dict = {
  one: {
    two: {
      three: "3",
      foo: "bar"
    }
  },
  bar: {
    baz: 2
  }
} as const

type Dict = typeof dict

type KeysUnion<T, Cache extends string = ''> =
  T extends PropertyKey ? Cache : {
    [P in keyof T]:
    P extends string
    ? Cache extends ''
    ? KeysUnion<T[P], `${P}`>
    : Cache | KeysUnion<T[P], `${Cache}.${P}`>
    : never
  }[keyof T]

type RemoveDot<T extends string> = T extends `.${infer Tail}` ? Tail : T;

type ExtractString<T extends string, U extends string, Result extends string = ''> =
  T extends `${infer Head}${infer Tail}` ? `${Result}${Head}` extends U ? Tail : ExtractString<Tail, U, `${Result}${Head}`> : Result

type ValidPrefix<T extends string, U extends string> = T extends `${U}${string}` ? Exclude<T, U> : never

type ConcatNamespaceWithPrefix<N extends string, P extends string> = `${N}.${P}`

type Values<T> = T[keyof T]

type Elem = string;

type Acc = Record<string, any>

// (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc
type Predicate<Accumulator extends Acc, El extends Elem> =
  El extends keyof Accumulator ? Accumulator[El] : Accumulator

type Reducer<
  Keys extends Elem,
  Accumulator extends Acc = {}
  > =
  Keys extends `${infer Prop}.${infer Rest}`
  ? Reducer<Rest, Predicate<Accumulator, Prop>>
  : Keys extends `${infer Last}`
  ? Predicate<Accumulator, Last>
  : never

type UseTranslationsProps<D = typeof dict> =
  (() => <Prefix extends KeysUnion<D>>(prefix: Prefix) => Reducer<Prefix, D>)
  & (
    <
      ValidKeys extends KeysUnion<D>,
      Namespace extends ValidKeys
      >(namespace?: Namespace) =>
      <Prefix extends RemoveDot<ExtractString<ValidPrefix<KeysUnion<Dict>, Namespace>, Namespace>>>(
        prefix: Prefix
      ) => Reducer<ConcatNamespaceWithPrefix<Namespace, Prefix>, D>
  )


declare const useTranslations: UseTranslationsProps;


{
  const t = useTranslations() // ok
  const ok = t('one') // {two: ....}
}
{
  const t = useTranslations('one.two') // ok
  const ok = t('three') // 3
}

{
  const t = useTranslations() // ok
  const ok = t('three') // expected error
}

Playground

您可能已经注意到,我在 UseTranslationsProps 中使用了两个函数的交集,它会产生一个重载来调用不带参数的函数。