1

I am trying to define a helper function that will parse an axios error and store the resultant error message into the specified field of the specified object.

I want the calling site to be:

axios.get(...).then(...).catch(ParseIntoErrorString(myClass, 'loadError');

where myClass is a TypeScript class that contains a method that is calling this code (probably from a 'loadData' method). myClass will likely be replaced with 'this' in most cases.

loadError is the name of a string property on myClass. I am modeling this after jest.spyOn, where you specify the object that you want to spy on and then the string argument is the name of the function you want to replace with a spy function. jest.spyOn has a nice property that it will give you an error if you provide a string that isn't a function of the spied on object.

I have tried something like this, but it isn't working at all:

type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? never : K }[keyof T] &
  string;

export function ParseErrorIntoString<T extends {}, P extends NonFunctionPropertyNames<Required<T>>>(obj: T, member: P): (reason: AxiosError) => void {
  return (reason: AxiosError): void => {
    // eslint-disable-next-line no-param-reassign
    obj[member] = AjaxParseError(reason);
  };
}

The NonFunctionPropertyNames was lifted right from the jest typings file.

And besides not working at the call site, this code itself throws up two errors:

  1. it complains that I can't use extends {} and suggests that I replace it with Record<string, unknown>, which is fine, but when I do that, I can't pass this to it at the call site because it says that this doesn't conform to Record<string, unknown>. It doesn't tell me why.
  2. the obj[member] says that I can't store a string into it. I suspect this is because there is no restriction of the NonFunctionPropertyNames that limits it to only allow string properties.

I have also tried this:

export function ParseErrorIntoString<T extends object>(obj: T, member: Extract<keyof T, string | null>): (reason: AxiosError) => void {
  return (reason: AxiosError): void => {
    // eslint-disable-next-line no-param-reassign
    obj[member] = AjaxParseError(reason);
  };
}

My expectation is that Extract<keyof T, string | null> will select all of the keys of T that are of type "string | null". But it does not. It does give me an error at the calling site if I pass in a non-member in the string, but it is too accepting. It will also allow me to provide the name of a boolean member without throwing an error.

Which is probably why it still throws an error at the obj[member] = ... line, saying that I can't assign a string to obj[member]. Also, I have to explicitly state T at the calling site for it to accept a member of T. I was assuming that like spyOn in jest, it would infer the type of T from the first argument.

Here is a more stand alone full example. This is the third attempt that is getting very close:

class cls {
  public str: string | null;
  public bln: boolean;

  constructor() {
    this.str = "";
    this.bln = false;
  }
}
   
const instance = new cls();

type StringPropertyNames<T> = { [K in keyof T]: T[K] extends string | null ? K : never }[keyof T] & string;

function f<T extends cls>(obj: T, p: StringPropertyNames<T>): void {
  obj[p] = "done";
}

f(instance, 'str'); // I want to be able to call f, with the instance and the property like this
console.log(instance.str) // should print out 'done'

in this complete example, f is the original ParseErrorIntoString function. In this third example, the calling site of f(instance, 'str') works as expected. I get an error when I try to call f(instance, 'bln') because bln is type boolean, not type string.

But I am still getting "Type 'string' is not assignable to type T[StringPropertyNames<T>]" on the obj[p] = 'done' line.

I think the StringPropertyNames<T> needs to be given some sort of return type to say that it is only returning keys of type string | null.

Any suggestions on the proper typing of the ParseErrorIntoString function?

7
  • Does this question depend on axios and jest? If so, do you want to edit the tags to include these? If not, could you edit the example code to be self-contained so that it demonstrates your issue when pasted into a standalone IDE (so, either remove or define third-party values/types)? In either case you should remove typescript-typings because it's not appropriate (I don't even know why that tag exists anymore since it's about a particular deprecated package, but I don't have the power to destroy it) Commented Oct 23, 2022 at 19:06
  • @jcalz, it does not depend on axios or jest. The third version is self contained and fails on the string assignment line within the function. This standalone code was tested in typescriptlang.org/play Commented Oct 23, 2022 at 19:28
  • Perhaps a link to the playground with this code preloaded would not be amiss, too help potential answerers by preloading a known-good IDE with settings as desired? Commented Oct 23, 2022 at 19:41
  • Does this approach meet your needs? If so I can write up an answer explaining; if not, what am I missing? (Pls mention @jcalz to notify me if you reply) Commented Oct 23, 2022 at 19:55
  • @HereticMonkey here is a link to the playground I have been using typescriptlang.org/play?#code/… Commented Oct 23, 2022 at 20:09

1 Answer 1

1

You've run into a current limitation or missing feature of TypeScript, documented at microsoft/TypeScript#48992. You'd like to say KeysOfType<T, string | null> to mean "the keys of type T whose properties have type string | null" and then use those keys to read/write string | null to/from a value of type T. There are ways to do this when T is some specific type, but when you're dealing with generics the compiler is unable to follow the logic when actually trying to read/write properties. That is, when T is generic, the compiler has no idea that T[KeysOfType<T, string | null>] is compatible with string | null, no matter how you define KeysOfType.

Until and unless this is implemented and we have a native KeysOfType that the compiler understands, the workaround I usually use is to reverse the constraint. Instead of a generic object type T and keys of type KeysOfType<T, string | null>, use a generic keylike type K, and then define your object type in terms of it like Record<K, string | null> (using the Record<K, V> utility type to say a type with keys of type K and values of type V). The compiler does understand that Record<K, string | null>[K] is compatible with string | null:

function f<K extends string>(
    obj: Record<K, string | null>,
    p: K
): void {
    obj[p] = "done"; // okay
}

And this works on your example code:

f(instance, 'str'); // okay

That's it, mostly.

There are other quirks you might run into around the difference between reading and writing (you really only care that you can write "done" to obj[p], not string | null, but it's hard to express that in a way that the compiler accepts) and excess property checks (if you call f({...}, 'str') with some object literal {...} the compiler might complain about the object literal having properties that are not str), but those are out of scope for the question as asked.

Playground link to code

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.