Home > Back-end >  How to define a function that returns an object with getter and setter based on string argument
How to define a function that returns an object with getter and setter based on string argument

Time:07-27

I am having trouble finding the right typing for a function that generates an object with setter and getter properties. I think my best bet is some use of template string literals, but I am having a hard time finding the magic combination of types. In pseudo-code JS, what I would like is something like this:

function createGetterAndSetter( key ) {
  return {
    [`get${capitalize(key)}`] : ( model ) => {
      return model[key]
    },
    [`set${capitalize(key)}`] : ( model, value ) => {
      return {
        ...model,
        [key] : value
      }
    }
  }
}

what I've tried so far is this:

interface Model {
  [key:string]: any
}
type ModelKey = keyof Model & string
type Setter = ( model : Model, value : unknown ) => Model
type Getter = ( model : Model ) => unknown
type GetterKey<T extends string> = `get${Capitalize<T>}`
type SetterKey<T extends string> = `set${Capitalize<T>}`
type GetterSetterKeys<T extends string> = GetterKey<T> | SetterKey<T>
type GetterSetter<T extends GetterKey | SetterKey> = Record<GetterSetterKeys<T>, Getter | Setter>

function createGetterAndSetter<T extends ModelKey>( key : ModelKey ) : GetterSetter<T> {
  const keyName = `${key[0].toUpperCase()   key.substring( 1 )}`

  return {
    [`set${keyName}`] : createSetValue( key, setOpts ) as Setter,
    [`get${keyName}`] : createGetValue( key, getOpts ) as Getter
  } as GetterSetter<T>
}

I'm quite new to TS, so this is probably nonsensical in some respects, but it does seem get the compiler to recognize that the return of createGetterAndSetter is an object with getX and setX keys, it just can't recognize whether the value for those keys is a getter or setter... it only knows it's one of the union of the two.

It may help to know that I was inspired by the createApi function of redux-toolkit, which is able to create named hooks for you based on your named endpoints.

CodePudding user response:

Here's a possible typing:

type GetterSetter<K extends string> =
  { [P in `get${Capitalize<K>}`]:
    <T extends { [Q in K]: any }>(model: T) => T[K]
  } &
  { [P in `set${Capitalize<K>}`]:
    <T extends { [Q in K]: any }>(model: T, value: T[K]) => T
  };
declare const getterSetter: <K extends string>(k: K) => GetterSetter<K>

So getterSetter() takes a key of generic type K and returns a value of type GetterSetter<K>. This type uses template literal types to make the method names from K, and the method types are themselves generic so that the return type of getXXX and the value parameter of setXXX are strongly typed with respect to the type of the model parameter.

The implementation of getterSetter could be:

const capitalize = (k: string) => k[0].toUpperCase()   k.substring(1);
const getterSetter = <K extends string>(k: K) => ({
  [`get${capitalize(k)}`]: (m: any) => m[k],
  [`set${capitalize(k)}`]: (m: any, v: any) => ({ ...m, [k]: v })
}) as GetterSetter<K>;

Note that I used a type assertion (as GetterSetter<K>) as well as the any type inside the implementation. That's because, while it's straightforward-ish to define the GetterSetter<K> typing, it's a lot trickier to get the compiler to verify that you've implemented it properly. It would need to, among other things, understand that captialize() performs at a value level the same thing the Capitalize<T> type function performs at the type level. And it would need to do better typing on computed properties (see ms/TS#13948), etc. It's a lot easier to just use any and as and triple check that we didn't do anything wrong, than it is to overcome these hurdles.

Anyway, let's try it out:

const foo = {
  prop1: "hey",
  prop2: 123,
  prop3: true
}

const p1 = getterSetter("prop1");
console.log(p1.getProp1(foo).toUpperCase()); // HEY

p1.getProp1({a: 123}) // error! {a: 123} not expected since it doesn't have a prop1 key

console.log(p1.getProp1({prop1: Math.PI}).toFixed(2)) // 3.14

const bar = p1.setProp1(foo, "you");
console.log(bar.prop1.toUpperCase()) // YOU

Looks good. The value p1 is of type GetterSetter<"prop1"> and therefore has a getProp1() and a setProp1() method, each of which behave well. The compiler understands that p1.getProp1({prop1: Math.PI}) is of type number, and that p1.getProp1({a: 123}) is an error. And the same with setProp1().

Playground link to code

  • Related