user13203487
user13203487

Reputation:

Array of property names into properties

I've got an object where each key is a function that could be used by a "processor".

const fns = {
  foo: () => ({ some: "data", for: "foo" }),
  bar: () => ({ and: "data", for: "bar" }),
  baz: () => ({ baz: "also", is: "here" }),
};

Then I've got an interface describing the specification of a "processor":

interface NeedyProcessor {
  needed: Array<keyof typeof fns>;
  // Question 1: Can needsMet be typed more strongly?
  process: (needsMet: any) => any;
}

My goal is that needed should be an array with the function properties in fns that this processor needs to process.

So if needed is ["foo"] it means that processor will get an object needsMet that has a single property, foo with the value of foo from fns.

I didn't know how to type this properly, and the lack of typing makes this error go under the radar of Typescript:

const broken: NeedyProcessor = {
  needed: ["foo", "baz"],
  process: (needsMet) => ({
    fooResult: needsMet.foo(),
    // Question 1 cont: So that this call would give an error in the IDE
    barResult: needsMet.bar(), // Runtime error! bar won't exist
    bazResult: needsMet.baz(),
  }),
};

Just for completion and perhaps to clarify a bit more here is also an example without a runtime error:

const working: NeedyProcessor = {
  needed: ["bar"],
  process: (needsMet) => ({
    barResult: needsMet.bar(),
  }),
};

I will have a function similar to this one for making the call. It's important to only pass in the needed fns to process:

function callProcessor(spec: NeedyProcessor) {
  // Question 2: Could this any be typed more strongly? Not as important as question 1 though
  const needsMet: any = {}

  spec.needed.forEach(x => needsMet[x] = fns[x])

  return spec.process(needsMet);
}

One will work and not the other:

console.log("working", callProcessor(working));
console.log("broken", callProcessor(broken));

Playground Link

Upvotes: 1

Views: 80

Answers (2)

jcalz
jcalz

Reputation: 329838

There isn't a good specific type that would meet your needs. Conceptually NeedyProcessor could be a union of all acceptable input types, like this:

type NuttyProfessor =
  { needed: []; process: (needsMet: Pick<Fns, never>) => any; } |
  { needed: ["baz"]; process: (needsMet: Pick<Fns, "baz">) => any; } |
  { needed: ["bar"]; process: (needsMet: Pick<Fns, "bar">) => any; } |
  { needed: ["bar", "baz"]; process: (needsMet: Pick<Fns, "bar" | "baz">) => any; } |
  { needed: ["baz", "bar"]; process: (needsMet: Pick<Fns, "bar" | "baz">) => any; } |
  { needed: ["foo"]; process: (needsMet: Pick<Fns, "foo">) => any; } |
  { needed: ["foo", "baz"]; process: (needsMet: Pick<Fns, "foo" | "baz">) => any; } |
  { needed: ["foo", "bar"]; process: (needsMet: Pick<Fns, "foo" | "bar">) => any; } |
  { needed: ["foo", "bar", "baz"]; process: (needsMet: Pick<Fns, "foo" | "bar" | "baz">) => any; } |
  { needed: ["foo", "baz", "bar"]; process: (needsMet: Pick<Fns, "foo" | "bar" | "baz">) => any; } |
  { needed: ["baz", "foo"]; process: (needsMet: Pick<Fns, "foo" | "baz">) => any; } |
  { needed: ["bar", "foo"]; process: (needsMet: Pick<Fns, "foo" | "bar">) => any; } |
  { needed: ["bar", "foo", "baz"]; process: (needsMet: Pick<Fns, "foo" | "bar" | "baz">) => any; } |
  { needed: ["bar", "baz", "foo"]; process: (needsMet: Pick<Fns, "foo" | "bar" | "baz">) => any; } |
  { needed: ["baz", "foo", "bar"]; process: (needsMet: Pick<Fns, "foo" | "bar" | "baz">) => any; } |
  { needed: ["baz", "bar", "foo"]; process: (needsMet: Pick<Fns, "foo" | "bar" | "baz">) => any; };

But that doesn't scale well at all with the size of fns, and because it's not a discriminated union it wouldn't even work the way you want... the process callback parameter cannot be contextually typed:

const shouldWorkButDoesNot: NuttyProfessor = {
  needed: ["bar"],
  process: (needsMet) => ({ // error!
    // ---> ~~~~~~~~ implicit any 😢
    barResult: needsMet.bar(),
  }),
};

Oh well.


Instead, you should make NeedyProcessor<K> generic in the type K of the elements of needed. And to make it so you don't have to specify K manually, you could write a helper function to infer K for you. It could look like this:

interface NeedyProcessor<K extends keyof Fns> {
  needed: Array<K>;
  process: (needsMet: Pick<Fns, K>) => any;
}

const needyProcessor = <K extends keyof Fns>(
  np: NeedyProcessor<K>) => np;

And now instead of writing const v: NeedyProcessor = {...}, you write const v = needyProcessor({...}):

const working = needyProcessor({
  needed: ["bar"],
  process: (needsMet) => ({
    barResult: needsMet.bar(),
  }),
});
// const working: NeedyProcessor<"bar">

And let's make sure it catches mistakes:

const broken = needyProcessor({
  needed: ["foo", "baz"],
  process: (needsMet) => ({
    fooResult: needsMet.foo(),
    barResult: needsMet.bar(), // compiler error
    bazResult: needsMet.baz(),
  }),
})

Looks good!


To get callProcessor working, then, you need to make it generic also. That could look like this:

function callProcessor<K extends keyof Fns>(spec: NeedyProcessor<K>) {
  const needsMet = {} as Pick<Fns, K>; // <-- need an assertion here 
  spec.needed.forEach(<P extends K>(x: P) =>
    needsMet[x] = fns[x]
  )
  return spec.process(needsMet);
}

Here needsMet is of type Pick<Fns, K>, the same as the callback parameter type of the process method of NeedyProcessor<K>. (In order to get that initialization to compile, I used a type assertion; after all, {} is not a valid value for that type. needsMet only becomes valid after the forEach() loop completes).

And let's make sure this also works as desired:

console.log("working", callProcessor({
  needed: ["bar"],
  process: (needsMet) => ({
    barResult: needsMet.bar(),
  }),
}));

console.log("broken", callProcessor({
  needed: ["foo", "baz"],
  process: (needsMet) => ({
    fooResult: needsMet.foo(),
    barResult: needsMet.bar(), // compiler error
    bazResult: needsMet.baz(),
  }),
}));

Looks good!

Playground link to code

Upvotes: 0

ShamPooSham
ShamPooSham

Reputation: 2379

I'm thinking you should use generics instead of an the needed property. Something like this:

const fns = {
  foo: () => ({ some: "data", for: "foo" }),
  bar: () => ({ and: "data", for: "bar" }),
  baz: () => ({ baz: "also", is: "here" }),
};

interface NeedyProcessor<T extends keyof typeof fns> {
  process: (needsMet: Pick<typeof fns, T>) => unknown;
}

const broken: NeedyProcessor<"foo" | "baz"> = {
  process: (needsMet) => ({
    fooResult: needsMet.foo(),
    barResult: needsMet.bar(), // Runtime error! bar won't exist
    bazResult: needsMet.baz(),
  }),
};

I'm sure there is a way to require the needed property to include all values in the union, but I can't come up with it now. Maybe infer can be used somehow to make it even easier?

=========

Edit:

I thought about it a bit more and came up with a solution that would keep the array of needed values in runtime.

type Needed = "foo" | "bar" | "baz"

const fns = {
  foo: () => ({ some: "data", for: "foo" }),
  bar: () => ({ and: "data", for: "bar" }),
  baz: () => ({ baz: "also", is: "here" }),
};

interface NeedyProcessor<T extends readonly Needed[]> {
  needed: T,
  // Instead of the return type any, 
  // you could use Record<string, ReturnType<typeof fns[T[number]]>> 
  // for the given "broken" example below.
  // Otherwise, I'd recommend using unknown instead of any
  process: (needsMet: Pick<typeof fns, T[number]>) => any; 
}

const broken: NeedyProcessor<["foo", "baz"] > = {
  needed: ["foo", "baz"] ,
  process: (needsMet) => ({
    fooResult: needsMet.foo(),
    barResult: needsMet.bar(), // Runtime error! bar won't exist
    bazResult: needsMet.baz(),
  }),
};

The only drawback is that the array of needed has to be sent as a generic type as well as to the needed field. If you prefer, you can create an as const array and use it in both the generic type and the needed value, like this:

const neededBroken = ["foo", "baz"] as const
const broken: NeedyProcessor<typeof neededBroken> = {
  needed: neededBroken,
  process: (needsMet) => ({
    fooResult: needsMet.foo(),
    barResult: needsMet.bar(), // Runtime error! bar won't exist
    bazResult: needsMet.baz(),
  }),
};

Upvotes: 0

Related Questions