cnotethegr8
cnotethegr8

Reputation: 7510

Typescript string autocomplete object structure midway

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?

Upvotes: 1

Views: 896

Answers (1)

First of all, useTranslations should expect valid dot notation of path. I mean it should expect a union of "one" | "one.two" | "one.two.three" | "one.two.foo". You will find explanation in the comments:

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

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

Now you know that your namespace is safe. We need to make sure that our second (curried) function expects valid prefix. I mean, if you provided one.two namespace, you are expect suffix to be either three or foo.

In order to do that, we need somehow to extract from "one" | "one.two" | "one.two.three" | "one.two.foo" all keys which are contain one.two. Further more we should get rid from obtained keys one.two prefix and leave only tail part.

We can use this helper to extraxt the suffix:


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

Ok, we have valid suffix, but we need to get rid of leading dot .three:


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

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

Playground

Please don't forget that we need to decide which keys we need to use with ExtractString. Because we need to extract prefix only from provided namespace and not from all keys.

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

We almost have our types. One thing remain. We need to infer our return type.


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

Here you have an explanation of reducer with pure js example. You can find explanation also in my article

Since we have all our required utils, we can type our function:

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

As you might have noticed, I have used intersection of two functions in UseTranslationsProps it produces an overloading for calling this function without argument.

Upvotes: 3

Related Questions