typescriptfunctiongenericsparametersextends

Extending the object type of a function argument


I want to extend the function argument type by adding a function with any name to each object and return it, but i am facing a problem with naming the key of the added function


export type Expand<T> = {
  [K in keyof T as K]: T[K] extends { [K in string]: unknown }
    ? ExpandData<T[K]>
    : T[K] | ((p: T) => T);
};
type ExpandData<T extends { [K in string]: unknown }> = T | Expand<T>;

export const create = <T,>(params: Expand<T>): Expand<T> => {
  return params;
};

I want to use it like this, where the function change: (val) => val will return me the value of the object where it is located ( with any name instead of "change" )


type ICreate = {
  param: {
    first: {
      value: number;
    }
  }
}

const result = create<ICreate>({param: {
  first: {
    value: 1,
    change: (val)=> val, // type val = { value: number }
  }
}})

and get as a result type in the form

  {
    param: {
      first: {
        value: number
        change: Function 
      }
    }
  }

How to do this correctly? ( ICreate type may change, I'm looking for a generic solution )


Solution

  • There really isn't a useful specific type that works this way. You'd like to say that you'll allow T to have any extra key not present in T, and the property at that key will be of type (val: T) => T. The stumbling block is that you can't say "any key except for keyof T". That would require something like a "rest index signature" or a "default property type" as requested in microsoft/TypeScript#17867. But that isn't part of the language. There are various workarounds discussed at How to define Typescript type as a dictionary of strings but with one numeric "id" property. You could certainly describe a generic constraint which validates objects like this, but it won't let you infer the parameter type of the functions you add. It's a mess to do it that way.

    It would be much much better for you to add a single property with a known key (like fns) that contains values of type (val: T) => T... pushing those functions down one level. That can be described as a specific type like this:

    type Expand<T> =
      T extends object ? {
        [K in keyof T]: Expand<T[K]> } &
      { fns?: { [k: string]: (val: T) => T } } :
      T
    

    It's a recursive conditional type that intersects each object type with an object optionally containing a fns property with an index signature. For

    type ICreate = {
      param: {
        first: {
          value: number;
        }
      }
    }
    

    the expanded version is equivalent to

    type ExpandICreate = Expand<ICreate>;
    /* type ExpandICreate = {
        param: {
            first: {
                value: number;
                fns?: { [k: string]: (val: { value: number }) => { value: number }}
            }
            fns?: { [k: string]: 
              (val: { first: { value: number }}) => { first: { value: number }}
            }
        }
        fns?: { [k: string]: (val: ICreate) => ICreate }
    } */
    

    That's enough to make your original version sort of work, but an index signature doesn't "remember" which keys are present, and optional properties don't "remember" if they are there or not:

    const create = <T,>(params: Expand<T>) => params;
    
    const result = create<ICreate>({
      param: {
        first: {
          value: 1,
          fns: {
            change: (val) => val,
          }
        }
      }
    });
    
    result.param.first.value.toFixed(); // okay
    result.param.first.fns.change({ value: 1 }); // error, fns might be undefined
    result.param.first.fns?.change({ value: 1 }); // okay
    result.param.first.fns?.whaaaaa({ value: 1 }); // also okay
    

    If you want TS to know that fns exists under first and that change exists under fns and that whaaaaaa does not exist under fns, then you need to make create() even more generic. You'd like to write

    const create = <T, U extends Expand<T>>(params: U) => params;
    

    but there's no way to call this where you manually specify T and have the compiler infer U. That would be microsoft/TypeScript#26242 and it isn't part of the language. The workaround here is currying:

    const create = <T,>() => <U extends Expand<T>>(params: U) => params;
    

    So you call create<ICreate>() and that returns a function you call with params:

    const result = create<ICreate>()({
      param: {
        first: {
          value: 1,
          fns: {
            change: (val) => val,
          }
        }
      }
    })
    
    result.param.first.value.toFixed(); // okay
    result.param.first.fns.change({ value: 1 }); // okay
    result.param.first.fns.whaaaaa({ value: 1 }); // error
    

    That now works how you'd like it.

    Playground link to code