0
votes

Learning TypeScript and hitting my head on the wall with this for the last 3 days. Can be simplified as this:

type StrKeyStrVal = {
    [key: string]: string
};

function setKeyVal<T extends StrKeyStrVal>(obj: T, key: keyof T, value: string) {
    obj[key] = value;
}

Basically, I have objects with string keys and (only) string values. I want to have a generic function that can set any string key on this object to any string value. Sounds very simple but TypeScript (strict) complains about the above obj[key] = value; assignment. I figured this happened after the following changes: https://github.com/microsoft/TypeScript/pull/30769

The above works fine before TypeScript 3.5 but the behavior was deemed unsafe. I can't see how this can be unsafe under these conditions. My object can only have string keys, the values can only be strings, T extends this type, obj is T, key is a keyof T, value is string, how can this ever be unsafe? What can I do to show TypeScript to show this is fine without jumping out of the type safety?

1
It doesn't work fine before 3.5: observejcalz
And it's unsafe here. If you want me to write this up I will, but afaik you need a cast or to refactor your codejcalz
Ah that example helps a lot, thank you. What would a refactor look like? Can I create a type for StrKeyStrVal that is prohibited from having narrower values than string? Basically I want to express the idea in the question within the type system. An object that can have ANY string value (and nothing narrower), and a generic function that can set any key in that object provided it provides an existing key and a string value.RestOfTheBothWorlds
Saying "nothing narrower" is hard to express in TypeScript and even harder to enforce. TypeScript decided not to make object property types invariant, and not to allow/require variance annotations. I think the best you can do is either embrace the unsoundness or be more restrictive with your call sites.jcalz

1 Answers

0
votes

It's unsafe for the same reason that this is unsafe: the caller chooses the type parameter, which can be narrower than the generic constraint, and one way to make a type narrower is to narrow its property types. So the type Shirt defined as

type Shirt = { size: "S" | "M" | "L", color: string }
const redShirt: Shirt = { size: "L", color: "red" };

is a subtype of StrKeyStrVal:

const strKeyStrVal: StrKeyStrVal = redShirt; // okay

and that's a problem for you, because the size property is strictly narrower than string, and if you're not careful you'll assign some unacceptable string to it:

setKeyVal(redShirt, "size", "XXXXXL"); // oops

So, what can you do? If you don't expect to run into the above situation you can just a type assertion and move on. If you want to avoid type assertions, you need to refactor.


One way to refactor which is just as unsafe as an assertion is to remove the generics:

function setKeyVal(obj: StrKeyStrVal, key: string, value: string) {
  obj[key] = value; // okay
}
setKeyVal(redShirt, "size", "XXXXXL"); // oops!

That's because TypeScript is unsafe in places, and non-generic supertype property writing is one such place. See microsoft/TypeScript#14150 for discussion about this. The conclusion of that issue is that it would be incredibly annoying for many people to use TypeScript if it didn't allow property writes to supertypes. So productivity wins out over type safety here. That might be what you want in this case.


One safer way to refactor is to make the function sufficiently generic that it will only accept the right strings for the right keys:

  function setKeyVal<T extends StrKeyStrVal, K extends keyof T>(obj: T, key: K, value: T[K]) {
    obj[key] = value; // okay now
  }

The implementation is now seen as safe, and the bad call above with redShirt now throws an error at the call site:

  setKeyVal(redShirt, "size", "XXXXXL"); // error!
  // -----------------------> ~~~~~~~~
  // Argument of type '"XXXXXL"' is not assignable to parameter of type '"S" | "M" | "L"'

Now we have a function which is harder to use incorrectly, but is also harder to use and harder to annotate. You get more type safety, but at some cost.


I guess it depends on your use cases and your priorities which way you want to proceed. In any case I hope this helps make sense of the situation. Good luck!

Playground link to code