1

Consider having an object for translating codes or for internationalization.

const statusMap = {
  'A': 'Active',
  'S': 'Suspended',
  'D': 'Inactive',
}

Getting values from this object works fine as long as you use the keys 'A' | 'S' | 'D' explicitly (statusMap.A), but when dealing with an unknown string (statusMap[queryParams.status]), we get the error "No index signature with a parameter of type 'string' was found on type". This works in plain JavaScript, so it's an issue with types. Ideally I would like statusMap[queryParams.status] to return string | undefined.

What's the most convenient and safe way of accessing object values using any string?

Is it possible to add an indexer to an object like this without creating an interface for it?


Here are some solutions I've already considered, but all of them have drawbacks.

  1. Casting the map to an indexed type
const statusMap: Record<string, string | undefined> = {
  'A': 'Active',
  'S': 'Suspended',
  'D': 'Inactive',
}

statusMap[anyString]

This works fine, but we just threw away autocompletion (typing statusMap. won't get us suggestions for A, S, or D anymore). Another small issue is that statusMap.A would result in string | undefined even though 'A' is there.

Moreover, it's difficult to enforce that every team member does the casting correctly. For example you could cast to just Record<string, string> and statusMap['nonexistent_key'] would result in a string type, even though the actual value is undefined.

  1. Casting the map on every access
(statusMap as Record<string, string | undefined>)[anyString]

This has the same problem of ensuring every team member does correct casts, needs to be repeated on every usage.

  1. Casting the indexing string on every access
statusMap[anyString as keyof typeof statusMap]

This is kind of ugly and also incorrect - there's no guarantee that anyString is actually 'A' | 'S' | 'D'.

  1. Using suppressImplicitAnyIndexErrors in tsconfig

Convenient, but not type safe, this just sidesteps the issue and the option has been deprecated since TypeScript 5.0.

  1. Utility function
function getValSafe<T extends {}>(obj: T, key: string | number): T[keyof T] | undefined {
  return (obj as any)[key]
}

getValSafe(statusMap, anyString)

This works quite well, but I don't like the idea of shoving a custom utility function into every single project just to do a basic operation in TypeScript.


Is there any better way of doing this?

Similar questions: this one uses an interface, but I want to avoid that, imagine having a huge i18n map or even a tree. This question uses a more complex translation function, I just want to use object maps like I would in regular JavaScript and still have type safety and autocompletion.

blade
  • 12,057
  • 7
  • 37
  • 38
  • Which version of typescript are you using? – wonderflame May 24 '23 at 09:44
  • The reason all of these solutions look clunky is that they are all an attempt to avoid doing what is supposed to be done: sanitise your inputs and handle incorrect inputs explicitly. This is why strict type checking was introduced, and any "solution" that ignores that is kind of meant to look wrong. – biziclop May 24 '23 at 09:45
  • @wonderflame 5.0.4 – blade May 24 '23 at 09:48
  • @biziclop How do you sanitize input that comes from an API or can be tampered by the user? – blade May 24 '23 at 09:49
  • @blade Using type guards, in this case you can create a simple one like `anyString in statusMap` – biziclop May 24 '23 at 09:53
  • Does [this approach](https://www.typescriptlang.org/play?ts=5.0.4#code/MYewdgzgLgBNCGUCuECy8AOMC8MDeAUDDAIIBcMARCcFAJYBuAppQDREwDKFlnKGTMABMmQthwAiPAJJh4tRi3YBfOIjoQAZnSYQYAJSagATkIA80Y3TABzVnChXbAPgDcBAqEiwAtpgFCnFCIKOgYFIYm5gDWTACeIJowUHECiQ4haJgwAD4wABSW1jYwAGT4ygCU9kUuOBwIyFkY7gR+GAFBmWEAdCStADZMsJY4VJSt7Z3BTWEA2pYAuu5AA) work for you? If yes, I'll write an answer explaining; If not, what am I missing? – wonderflame May 24 '23 at 09:54
  • 1
    @biziclop Could you elaborate on that in an answer? It sounds like a good solution, but `if (anyString in statusMap) console.log(statusMap[anyString])` still produces the same TS error. – blade May 24 '23 at 09:58
  • Probably he means something like [this](https://www.typescriptlang.org/play?ts=5.0.4#code/MYewdgzgLgBNCGUCuECy8AOMC8MDeAUDDAIIBcMARCcFAJYBuAppQDREwDKFlnKGTMABMmQthwAiPAJJh4tRi3YBfOIjoQAZnSYQYAJSagATkIA80Y3TABzVnChXbAPgDcBAqEiwNnKIhR0DABpJgBPHBgACnhjGwokMABrMBAAdzAASgpYmxgNGCTwkE0YKDCBEocAtEwcZ3wOYyZkYzAyiqYq3JxsXEpLaxtKGAAyUZge62rkWox3ZXcCABsWybAwvyc8-sp3OlKo339ZoNCwmI2toczMxuIEU8wAbXgrxyGAXQWCIA) – wonderflame May 24 '23 at 10:01
  • @wonderflame please do, this seems like a good solution, even if a bit verbose. It seems to work without the `satisfies` part or by replacing `(string & {})` with just a `string`, so if you could also explain that briefly. – blade May 24 '23 at 10:03
  • which one? do you want type guard or with `(string & {})`? I added satisfies for extra type checking to make sure that status map is `Record` – wonderflame May 24 '23 at 10:04
  • Ah, OK, I wasn't familiar with the satisfies constraint, that's pretty useful. I still don't get the `(string & {})` part. – blade May 24 '23 at 10:06
  • 1
    if you would write `Record<'A' | 'B' | 'C' | string, string>` that would end up with just string, since `'A' | 'B' | 'C'` is a subset of `string`. With adding `string & {}` that allows us to still see the `'A' | 'B' | 'C'` in the autocomplete and accept any `string` as well – wonderflame May 24 '23 at 10:08
  • Basically, `& {}` prevents the compiler from simplifying to just `string` – wonderflame May 24 '23 at 10:09
  • Does that fully address the question? Should I write a solution, If yes, which one? or maybe both? – wonderflame May 24 '23 at 10:10
  • Sure. Ideally both, having more options is always welcome. – blade May 24 '23 at 10:13

3 Answers3

2

First, I would suggest using satisfies operator to type check whether statusMap is a Record<string, string>:

const statusMap = {
  A: "Active",
  S: "Suspended",
  D: "Inactive",
} satisfies Record<string, string>;

Option 1: We can define an object that will be a Record<"A" | "S" | "D" | string, string>, however, if you would try defining an object with this type, you would get Record<string, string>, since "A" | "S" | "D" is a subset of string and the compiler will simplify the keys to just string. To prevent this, instead of adding just string, we can add an intersection: string & {}. {} doesn't bother us, since string extends {} and the result will be string anyway, though it will prevent from simplifying which is exactly what we need:

const statusMap = {
  A: "Active",
  S: "Suspended",
  D: "Inactive",
} satisfies Record<string, string>;

const mappedStatusMap: Record<keyof typeof statusMap | (string & {}), string> =
  statusMap;

playground

Option 2: Create a custom type guard to check whether any string is in the statusMap:

const isStatusMapKey = (arg: unknown): arg is keyof typeof statusMap => {
  return typeof arg === "string" && arg in statusMap;
};

Usage:

if (isStatusMapKey(anyString)) {
  statusMap[anyString];
}

playground

wonderflame
  • 6,759
  • 1
  • 1
  • 17
  • Looks great. Would it be possible to do something like the first option directly on statusMap without having to use mappedStatusMap? I suspect not, given that you need `typeof statusMap` in the type definition. – blade May 24 '23 at 10:26
  • if you know the keys of `statusMap` beforehand then yes, though the code would be really repetitive. `const statusMap: Record<"A" | "S" | "D" | string & {}, string>` – wonderflame May 24 '23 at 10:29
  • `satisfies` is a cool feature, didn't know about that – biziclop May 24 '23 at 13:05
1

The problem with many of these approaches is that they just try to trick the type checker into accepting incorrect values and pretending that they're correct. Which is the opposite of what strict type checking was created for. (This is because unlike say in Java, TS type checking doesn't enforce type conformity at runtime.)

So we can try to actually check the value of anyString at runtime instead, sanitising the input.

In theory this is exactly what the in type guard was created for, unfortunately the type checker can't work it out yet when property name is not a constant.

So you need to build a type predicate:

const isStatus = (str: string): str is keyof typeof statusMap => str in statusMap; 

Now you can do:

const statusMsg = isStatus(anyString) ? statusMap[str] : undefined;

Or even better, instead of returning undefined, you can stop processing the input and display an error message. Or use a default "unknown status" value. Once you handle incorrect inputs explicitly, the choice is in your hands.

biziclop
  • 48,926
  • 12
  • 77
  • 104
0

You need to use satisfies:

const statusMap = {
  'A': 'Active',
  'S': 'Suspended',
  'D': 'Inactive',
  'E': 12, // error
} as const satisfies Record<string,string>

statusMap.A // with autocomplete
  • `let anyString: string = ''; statusMap[anyString]` doesn't seem to work - "No index signature" error – blade May 24 '23 at 10:17