typescripttypes

Validating correlation between object key and string content of value


I'm trying to do a complex validation where I want the name of the key of an object to be present within the string that is assigned as value to that object.

const DatoButtonColumnSectionFragment = "fragment DatoNotSameKeyFragment {}"
const DatoCatalogSectionFragment = "fragment DatoCatalogSectionFragment {}"

type SectionFragmentMapGeneric = {
  [K in `Dato${string}SectionFragment`]: string
}

type SectionFragmentMap<T extends SectionFragmentMapGeneric> = {
  [K in keyof T]: K extends string ? `${string}${K}${string}` : never
}

// Declare SectionFragments object without immediate type enforcement
const SectionFragmentsUnvalidated = {
  DatoButtonColumnSectionFragment,
  DatoCatalogSectionFragment,
} as const satisfies SectionFragmentMapGeneric

const SectionFragments =
  SectionFragmentsUnvalidated satisfies SectionFragmentMap<
    typeof SectionFragmentsUnvalidated
  >

I got it working like this, but the satisfies happening in two steps is not great, cause it adds complexity and the IDE can't highlight the exact key for the error. Is there a way to workaround this?

If I don't use T extends SectionFragmentMapGeneric, it will accept any string that matches Dato${string}SectionFragment, and won't associate specific keys with specific values. And if I move the second satisfies to the initial declaration, the map becomes any since it is used on it's own initializer.


Solution

  • Since there is no specific type in TypeScript corresponding to your requirement, you need to use a generic type. TypeScript doesn't currently know how to infer generic type arguments in generic types (as requested in microsoft/TypeScript#32794), so the best one can can do is use a generic function. You can write a generic helper identity function that just returns its input, which uses a constraint to provide the checking you desire. Possibly like this:

    const asSectionFragmentMap = <T extends { [K in keyof T]:
      K extends `Dato${string}SectionFragment` ? `${string}${K}${string}` : never
    }>(t: T) => t;
    

    This is a recursive constraint. When you call asSectionFragmentMap(obj), TypeScript will make sure that the type of obj matches the mapped type. For each key K, it checks via conditional type if the key matches `Dato${string}SectionFragment` or not. If not, then the property type is checked against never so it will reject the property no matter what. If so, then the property type is checked against `${string}${K}${string}`. Let's test it out:

    const DatoButtonColumnSectionFragment = "fragment DatoNotSameKeyFragment {}"
    const DatoCatalogSectionFragment = "fragment DatoCatalogSectionFragment {}"
    
    const sectionFragments = asSectionFragmentMap({
      DatoButtonColumnSectionFragment,
      // Type '"fragment DatoNotSameKeyFragment {}"' is not assignable to type 
      // '`${string}DatoButtonColumnSectionFragment${string}`'.
      DatoCatalogSectionFragment,
      Oopsie: "abc" // error!
      //~~~~ <-- Type 'string' is not assignable to type 'never'.
    });
    

    Looks good. DatoButtonColumnSectionFragment has the wrong type, and the error message reflects that. DatoCatalogSectionFragment is acceptable. And since Oopsie doesn't match Dato⋯SectionFragment, it's an error also.

    Playground link to code