Search code examples
reactjstypescriptinterfacereact-contextopenapi-generator

Typescript: Extend interface dynamically by adding new fields with certain naming


I have an interface like this:

interface ProjectCostData {
  purchasePrice: number;
  propertyValue: number;
  recentlyDamaged: boolean;
}

Now I want to create an interface like this dynamically based on the interface above:

interface ProjectCostDataWithSetters {
  purchasePrice: number;
  setPurchasePrice: Dispatch<SetStateAction<number>>;
  propertyValue: number;
  setPropertyValue: Dispatch<SetStateAction<number>>;
  recentlyDamaged: boolean;
  setRecentlyDamage: Dispatch<SetStateAction<boolean>>;
}

--> Map over the first interface and for each entry A add a new entry B with the key `set${entryKey}` and a type that can be derived from entry A.

Is this possible in TS?

Background: I have a backend based on OpenAPI that provides me the API structure as a .yaml file. I pass that file through a generator and receive interfaces and functions to talk to the API. In the frontend I am using react context to build a global state. I would like to build my global react context based on the output of the generator.


Solution

  • In typescript 4.0 or older, this isn't possible. Strings, including property names, are either fully known as constants (i.e. "foo"), one of a set of limited possibilities of constants (i.e. "foo" | "bar"), or just any string with any content (string). So you cannot construct new property names based on other property names.


    However, Typescript 4.1 (currently beta) has some features that may help here in template literal types

    interface ProjectCostData {
      purchasePrice: number;
      propertyValue: number;
      recentlyDamaged: boolean;
    }
    
    // Create a new setter property for each key of an interface
    type WithSetters<T extends { [k: string]: any }> = T & {
        [K in keyof T & string as `set_${K}`]: (newValue: T[K]) => void
    }
    
    // Testing
    declare const obj: WithSetters<ProjectCostData>
    obj.purchasePrice // number
    obj.set_purchasePrice(123) // (newValue: number) => void
    

    Playground

    That:

    [K in keyof T & string as `set_${K}`]
    

    Is a bit funky. This PR explains that better than I could.


    You could even handle the capitalization change:

    interface Capitals { a: 'A', b: 'B', c: 'C' /* ... */, p: 'P' }
    
    type Capitalize<T extends string> =
        T extends `${infer First}${infer Rest}`
            ? First extends keyof Capitals
                ? `${Capitals[First]}${Rest}`
                : T
            : T
    
    
    type SetterName<T extends string> = `set${Capitalize<T>}`
    
    type SetPropName = SetterName<'propName'> // type: "setPropName"
    

    Playground

    Putting all that together gets a bit tricky, especially on the beta release. But I think, in theory, all the pieces exist to pull off this type transformation.