Home > other >  Typescript string autocomplete object structure midway
Typescript string autocomplete object structure midway

Time:11-03

I am trying to add type support for the function useTranslations in the next-intl package.

Lets assume my ./locales/en.ts file looks like this

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

The useTranslations function accepts an optional argument for namespace. It returns another function which when called has a required namespace argument. By setting the optional argument, it reduces the need to apply that part of the namespace to the required argument. Here's an example:

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

I have managed to get the types working for creating a string representation of the object, but I am having trouble with the first argument continuing into the second.

Here is my code so far

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)

I am able to set the main function like,

const t = useTranslations("one.two")

However I get an error for,

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

How can the type be adjusted to resolve this?

CodePudding user response:

First of all, useTranslations should expect valid dot notation of path:


const dict = {
  one: {
    two: {
      three: "3",
      foo: "bar"
    }
  }
} 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 Result = "one" | "one.two" | "one.two.three" | "one.two.foo"
type Result = KeysUnion<Dict>

You can find related answers:[ here, here, here, here] and in my article

Now you know that your namespace is safe.

interface UseTranslationsProps<D = typeof dict> {
  (namespace?: KeysUnion<Dict>): UseTranslationsReturn<D>
}

declare const useTranslations: UseTranslationsProps;

const dict = {
  one: {
    two: {
      three: "3",
      foo: "bar"
    }
  }
} 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]

const t = useTranslations('one.two') // ok
useTranslations('hello') // expected error

useTranslation should return curried function which in turn should expect path which is a valid property of namespace.

COnsider this example:


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 UseTranslationsProps<D = typeof dict> =
  <
    ValidKeys extends KeysUnion<D>,
    Namespace extends ValidKeys
    >(namespace?: Namespace) =>
    (
      prefix: RemoveDot<ExtractString<ValidPrefix<KeysUnion<Dict>, Namespace>, Namespace>>
    ) => void

declare const useTranslations: UseTranslationsProps;

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]

const t = useTranslations('one.two') // ok
useTranslations('one') // expected error

const result = t('three') // ok
t('foo') // Ok
t('one') // expected error

Playground

Now, you are allowed to use only valid prefixes to your namespace. We still need to pick apropriate property based on our namespace and prefix. The algorithm: namespace "." prefix

Full code:

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 dict = {
  one: {
    two: {
      three: "3",
      foo: "bar"
    }
  },
  bar: {
    baz: 2
  }
} as const

{
  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

Please let me know if it is what you are looking for. If yes, I will write more explanation: step-by-step

I believe above code can be refactored and simplified a bit. I will provide more explanation in the evening

  • Related