0

This question is more of the nature of best practices than why or why not something must work in a certain way.

After reading Why doesn't Object.keys return a keyof type in TypeScript? and a bunch of other documentation, I think I get why the return type of Object.keys can not be assumed to be keyof typeof myArray.

But, assuming Object.entries suffers from the same issue/limitation as Object.keys, it is still confusing to me that this works:

const map = {
  a: (str: string) => str,
  b: (str: string) => str,
  c: (str: string) => str,
};

function getFn(fnName: string) {
  const fn = Object.entries(map).find((e) => e[0] === fnName)?.[1];
  // Type:
  // const fn: ((str: string) => string) | undefined

  return fn;
}
// Type:
// function getFn(fnName: string): ((str: string) => string) | undefined

I don't use any type annotations or casts and let the typescript compiler do all the work through inference. Still this returns ((str: string) => string) | undefined, which is exactly what I want - either it found the requested function (str: string) => string or it didn't undefined.

Is this good practice? Is it just better to use an array of objects with key/value pairs directly, instead of an object literal in this type of cases? Or is it a compiler error?

UPDATE: More examples to better explain where I am coming from with this question.

const map = {
  a: (str: string) => str,
  b: (str: string) => str,
  c: (str: string) => str,
};

// With Object.entries and Array.find (WORKS)
function getFn(fnName: string) {
  const fn = Object.entries(map).find((e) => e[0] === fnName)?.[1];
  // Type:
  // const fn: ((str: string) => string) | undefined

  return fn;
}
// Type:
// function getFn(fnName: string): ((str: string) => string) | undefined

// With assertions (WORKS BUT CLUNKY)
function getFn2(fnName: string) {
  if (fnName === 'a' || fnName === 'b' || fnName === 'c') {
    return map[fnName];
  }
}
// Type:
// function getFn2(fnName: string): ((str: string) => string) | undefined

// With assertions - using Object.keys (DOES NOT WORK)
function getFn3(fnName: string) {
  if (Object.keys(map).includes(fnName)) {
    return map[fnName]; // ts(7053)
  }
}
// Type:
// function getFn3(fnName: string): any

// With assertions - using Object.entries (ALSO DOES NOT WORK)
function getFn4(fnName: string) {
  if (
    Object.entries(map)
      .map((e) => e[0])
      .includes(fnName)
  ) {
    return map[fnName]; // ts(7053)
  }
}
// Type:
// function getFn4(fnName: string): any

// With Reflect (WORKS BUT RETURNS 'ANY' OR REQUIRES MANUAL TYPING)
function getFn5(fnName: string) {
  const fn: undefined | ((str: string) => string) = Reflect.get(map, fnName);

  return fn;
}
// Type:
// function getFn5(fnName: string): ((str: string) => string) | undefined

Mikael Lirbank
  • 4,355
  • 2
  • 28
  • 25
  • Why are you declaring your function to take a string instead of `keyof typeof map`? It could just be [this easy](https://www.typescriptlang.org/play/#code/MYewdgzgLgBAtgQwA4wLwwN4CgYwQLhgApoAnQsgSzAHMBKNAPhjIBocYAjQkqclvtXpMBpdrmA8yFQbQapmbLAF8A3FiwAzAK5hgUSuBg0AplABiYImARwThANYmAniE0wozpCbfxkDbFxcUjNtUjA-JABtGzsAXXVlLCA)... – Jared Smith May 18 '20 at 21:07
  • This is a contrived example, in the real case `fnName` is provided from another system and all we know about it is that it's a string. I used a function that takes a string in an attempt to visualize what is provided is a string. Example: https://www.typescriptlang.org/play/#code/MYewdgzgLgBAtgQwA4wLwwN4CgYwQLhgApoAnQsgSzAHMBKNAPhjIBocYAjQkqclvtXpMBpdrmA8yFQbQapmbLAF8A3FiwAzAK5hgUSuBg0AplABiYImARwThANYmAniE0wozpCbfxkDbFxcUjNtUjA-JABtGzsAXXVlDVBIWCgACxMAZVkac1IQOAB5DJNSLOdoEzgZUiE0GAByBEb1LFMLK1Kcutp8wpLM8sqoarogA – Mikael Lirbank May 18 '20 at 23:19

2 Answers2

1

Ok, there are basically two ways to do this. Neither of them will let you get by without some manual typing: the TS compiler is already a pretty marvelous feat of engineering to do as well as it does with inference given that you start with not just a dynamic language but an ultra-dynamic language.

Solution 1: simple cast, recommended

function getFn(name: string) {
  if (name in map) { // for more safety, ['a', 'b', 'c'].includes(name)
    return map[name as keyof typeof map];
  }
  return;
}

Solution 2: user-defined type guard.

// More type-safe in more complex use-cases, probably overkill here.
function inMap(name: string | keyof typeof map): name is keyof typeof map {
  // As above, depending on how you want to play this
  // for more safety use ['a', 'b', 'c'].includes(name)
  return name in map; 
}

function getFn2(name: string) {
  if (inMap(name)) {
    return map[name];
  }
  return;
}

Playground

Jared Smith
  • 19,721
  • 5
  • 45
  • 83
  • Thanks for your answer. I understand this is how you typically solve it. But, why not just simply use what I initially proposed? Eg: `const getFn = (fnName: string) => Object.entries(map).find((e) => e[0] === fnName)?.[1];` 100% inference, not manual typings etc. – Mikael Lirbank May 18 '20 at 23:40
  • @MikaelLirbank because 1. it's slower 2. It's less clear and 3. it's not idiomatic. Why bend over backwards to avoid writing out a simple cast that you know is safe even if the compiler can't prove it without help? The disadvantages to that approach are not terrible, but there's zero advantage over a faster idiomatic readable solution. What's so terrible about manual typing that you'd contort your code to avoid it? – Jared Smith May 18 '20 at 23:45
  • My objective is to let the compiler do as much work as possible. Ryan Cavanaugh is explaining why the return type from Object.keys is string[] instead of an array of a union of the keys of the object. So like you say, it should not work, but then I found the way with Object.entries, and it just seem like that should not work either. But what do I know? – Mikael Lirbank May 18 '20 at 23:45
  • I don't know. What I *do* know is that I've been burned, repeatedly, both by myself and others, with code that was clever instead of clear. YMMV, but no one in their right mind would advocate iterating an object, allocating an extra array, and then iterating that array over a fast one-line O(1) hash lookup. – Jared Smith May 18 '20 at 23:47
  • Hehe, let's agree to disagree on that one. IMHO I believe the less you help the compiler, the cleaner the code. But again, thanks a bunch for spending time with me on this! I appreciate it. – Mikael Lirbank May 18 '20 at 23:53
  • I do agree that iterating on an object is not the smartest thing in the world. Was just trying to give an example of something I find weird works. Yeah, I agree to most of what you say. Just curious to what is common practice in a scenario like this. It just seems so hard to do something so common. Anyhow, thanks again! – Mikael Lirbank May 18 '20 at 23:57
0

Funny, a simple way to approach this is to loosen up the type of the map object a bit:

playground

const map: Record<string, ((str: string) => string) | undefined> = {
  a: (str: string) => str,
  b: (str: string) => str,
  c: (str: string) => str,
};

const key: string = 'stuff'
const fn = map[key]
// Type -> const fn: ((str: string) => string) | undefined

Alternatively use an actual Map object:

playground

const map = new Map<string, (str: string) => string>()
map.set('a', (str: string) => str)
map.set('b', (str: string) => str)
map.set('c', (str: string) => str)

const key: string = 'stuff'
const fn = map.get(key)
// Type -> const fn: ((str: string) => string) | undefined

No iterations or arrays, and no type casts. Awesome Inc.

Mikael Lirbank
  • 4,355
  • 2
  • 28
  • 25