简体   繁体   中英

Modify object values by finding keys dynamically TypeScript

I'm trying to write a function that returns another function that lets me modify the keys of an object in TypeScript. This is for use within a React reducer.

If I have a state object with keys dogs , dogIds , and another state object with keys cats and catIds , I'd like to write a patchGroup(group: string) so that if I pass in cat or dog , it returns a function that lets me modify those keys.

I'm new to TypeScript, so I tried indexing with strings. However, TypeScript errors out because I can't use strings to index my other types ...

Example of explicitly stating/using types

For example, I define the following state for dogs:

interface DogsState {
  dogs: Array<Dogs>;
  dogIds: Array<string>;
  loading: boolean;
  // etc. 
} 

and I can modify the state via a helper function, patchDog :

function patchDog(
  state: DogsState,
  payload: DogResponse | DogCreateRequest,
  ): DogsState {
    const dogIndex = state.dogIds.indexOf(payload.dog.dogId)
    return {
      ...state,
      loading: false,
      dogs: [...state.dogs, payload.dog],
      dogIds: [...state.dogIds, payload.dog.dogId]
    }
}

I now want one for Cats:

interface CatsState {
  cats: Array<Cats>;
  catIds: Array<string>;
  loading: boolean;
  // etc. 
} 

It's trivial to slightly modify patchDog , and I don't want to duplicate my code.

Current attempt with strings (not working)


const patchGroup = (group: string) => {
  // similar logic to `patchDog`
  const patch = (
    state: DogsState | CatsState,
    payload: DogResponse | DogCreateRequest | CatResponse | CatCreateRequest,
  ) => {
    const groupIdx = state[`{$group}Ids`].indexOf(payload[`${group}`][`${group}Id`]);
  // string failed; need to get the exact key somehow
  }

  return patch;
}

// ================= State Objects =================
interface DogsState {
  dogs: Array<Dog>;
  dogIds: Array<string>;
  loading: boolean;
  // etc. 
} 

interface CatsState {
  cats: Array<Cat>;
  catIds: Array<string>;
  loading: boolean;
  // etc. 
} 

// ================= Requests =================
type DogRequest = {
  dogId: string;
};
type DogCreateRequest = {
  dog: Dog;
};

type CatRequest = {
  catId: string;
};

type CatCreateRequest = {
  cat: Cat;
};

// ================= Responses =================
type DogResponse = {
  dog: Dog;
  lastUpdate: number;
  error: string;
}
type CatResponse = {
  cat: Cat;
  lastUpdate: number;
  error: string;
}

// ================= App Objects =================
interface Dog {
  dogId: string;
  name: string;
  // ... etc.
}

interface Cat {
  catId: string;
  name: string;
  // etc.
}

So far, I've looked into:

The main issue with expecting TypeScript to verify type safety in your scenario is that TypeScript currently does not have a way to concatenate string literal types . See microsoft/TypeScript#12940 for discussion about this, and microsoft/TypeScript#12754 for a suggestion that, if implemented, would allow this. So while the compiler understands that "cat" is a literal string type and can distinguish it from "dog" , it has no way to know that the "cat"+"Id" will result in the string literal type "catId" . It only knows that "cat"+"Id" is of the type string .

And so it doesn't know what to do when you index into a Cat with "cat"+"Id" . It can't index into a Cat with a generic string due to the lack of string index signature.


So it depends what your goal here is. If your goal is just to suppress the errors and not worry about the compiler verifying safety, then you can start using type assertions like this:

state[`{$group}Ids` as keyof typeof state]

But then you run into a new issue... your internal function seems to accept state and payload which is the union of the Cat and Dog versions. And that's not type safe either, since it allows you to call patchGroup("dog")(someCatState, someCatPayload) :

patchGroup("dog")(catsState, catResponse); // no error, oops

In order to suppress these legitimate type errors you'd need to start asserting all over the place like this:

const patchGroup = (group: string) => {
  const patch = (
    state: DogsState | CatsState,
    payload: DogResponse | DogCreateRequest | CatResponse | CatCreateRequest,
  ): DogsState | CatsState => {
    const groupIdx = (state[`{$group}Ids` as keyof typeof state] as any as Array<string>).
      indexOf((payload[`${group}` as keyof typeof payload] as Cat | Dog)[`${group}Id` as keyof (Cat | Dog)] as any as string);
    return {
      ...state,
      loading: false,
      [`{$group}s`]: [...state[`{$group}s` as keyof typeof state] as any as Array<Cat | Dog>, payload[group as keyof typeof payload]],
      [`{$group}Ids`]: [...state[`{$group}Ids` as keyof typeof state] as any as Array<string>, payload[group as keyof typeof payload][`{$group}Id`]]
    } as any as DogsState | CatsState;
  }
  return patch;
}

At which point you might as well just go all the way to any :

const patchGroup2 = (group: string) => {
  const patch = (
    _state: DogsState | CatsState,
    _payload: DogResponse | DogCreateRequest | CatResponse | CatCreateRequest,
  ): DogsState | CatsState => {
    const state: any = _state;
    const payload: any = _payload;
    const groupIdx = state[`{$group}Ids`].indexOf((payload[`${group}`])[`${group}Id`]);
    return {
      ...state,
      loading: false,
      [`{$group}s`]: [...state[`{$group}s`], payload[group]],
      [`{$group}Ids`]: [...state[`{$group}Ids`], payload[group][`{$group}Id`]]
    };
  }
  return patch;
}

Blah.


The next step toward regaining some type safety from the call side would be to make patchGroup() a generic function that only allows itself to be called with all Cat or all Dog inputs:

interface PatchGroup {
  cat: (state: CatsState, payload: CatResponse | CatCreateRequest) => CatsState,
  dog: (state: DogsState, payload: DogResponse | DogCreateRequest) => DogsState
}
const patchGroup = <K extends keyof PatchGroup>(group: K): PatchGroup[K] =>
  (state: any, payload: any) => {
    const groupIdx = state[`{$group}Ids`].indexOf((payload[`${group}`])[`${group}Id`]);
    return {
      ...state,
      loading: false,
      [`{$group}s`]: [...state[`{$group}s`], payload[group]],
      [`{$group}Ids`]: [...state[`{$group}Ids`], payload[group][`{$group}Id`]]
    };
  }

The implementation of patchGroup() is still not type safe, but at least the callers are presented with a safe type signature that won't accept mismatched inputs:

patchGroup("dog")(catsState, catResponse); // error now
// -------------> ~~~~~~~~~
// 'CatsState' is not assignable to 'DogsState'.

Trying to get the implementation of patchGroup() to be verified by the compiler as type safe is probably just not worth the effort. One of the stumbling blocks here is that there isn't support for what I call correlated record types . The relationships between CatXXX interfaces and the relationships between DogXXX interfaces are hard to represent as relationships between CatXXX | DogXXX CatXXX | DogXXX unions. Even if I told the compiler everything it needed to know about the relationship between the string literals used in your question, like this:

interface CatBundle {
  ks: "cats",
  id: "catId",
  ids: "catIds"
  obj: Cat,
  req: CatRequest,
  crq: CatCreateRequest,
  rsp: CatResponse,
  stt: CatsState
}
interface DogBundle {
  ks: "dogs",
  id: "dogId",
  ids: "dogIds"
  obj: Dog,
  req: DogRequest,
  crq: DogCreateRequest,
  rsp: DogResponse,
  stt: DogsState
}
interface Animals {
  dog: DogBundle,
  cat: CatBundle
}

// start implementation
const patchGroup = <K extends keyof Animals>(
  k: K) => (state: Animals[K]['stt'], payload: Animals[K]['rsp'] | Animals[K]['crq']
  ): Animals[K]['stt'] => {
    const id = k + "Id" as Animals[K]["id"];
    const ks = k + "s" as Animals[K]["ks"];
    const ids = k + "Ids" as Animals[K]["ids"];

The compiler will still balk at every indexing operation:

    const groupIdx = state[ids].indexOf(payload[k][id]); // error!
    //               ~~~~~~~~~~         ~~~~~~~~~~
    // Animals[K]["ids"] cannot be used to index type 'Animals[K]["stt"]
    // Type 'K' cannot be used to index type 'Animals[K]["rsp"] | Animals[K]["crq"]'.

So we've probably gone farther than where we can expect the compiler to help us. With concatenating property names and using unions of types, the best I can think of is the call-safe implement-unsafe version above.


From here, the next step to making this sane would be to stop trying to force the compiler to understand this sort of string-concatenation and computed-property code, and instead refactor your Cat and Dog interfaces to extend a single Animal interface. Don't give properties nonce names like catIds and dogIds ; instead just give them the same animalIds name:

interface AnimalState<A extends Animal> {
  animals: Array<A>;
  animalIds: Array<string>;
  loading: boolean;
  // etc
}

interface DogsState {
  animals: Array<Dog>;
  animalIds: Array<string>;
  loading: boolean;
  // etc. 
}

interface CatsState {
  animals: Array<Cat>;
  animalIds: Array<string>;
  loading: boolean;
  // etc. 
}

You can refactor all your Dog and Cat types to the new Animal -compatible type (code written out in link at bottom) and then you don't need a patchGroup() anymore. You can have a single generic patch function:

const patch = <A extends Animal>(
  state: AnimalState<A>,
  payload: AnimalResponse<A> | AnimalCreateRequest<A>,
): AnimalState<A> => {
  const groupIdx = state.animalIds.indexOf(payload.animal.animalId);
  return {
    ...state,
    loading: false,
    animals: [...state.animals, payload.animal],
    animalIds: [...state.animalIds, payload.animal.animalId]
  };
}

This implementation is verified as safe by the compiler, and will be safe at the call site too:

patch(catsState, catResponse); // okay
patch(dogsState, dogResponse); // okay
patch(catsState, dogResponse); // error! DogResponse not valid

There might well be reasons why you can't do that refactoring, but it's so much nicer to work with that I'd be tempted to try anyway, or, failing that, to forget about refactoring your original duplicated code to a single implementation; the duplication is annoying but at least the compiler is still helping you.

Okay, hope that helps; good luck!

Playground link to code

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM