I have a helper function for getting an entry from a map, adding it if it's not present already.
export function mapGetOrCreate<K, V>(map: Map<K, V>, key: K, valueFn: (key: K) => V): V {
let value = map.get(key);
if (value === undefined) {
value = valueFn(key);
assert(value !== undefined);
map.set(key, value);
}
return value;
}
TypeScript's unsoundness around generic type variance ends up causing this practical issue for me:
type A = {a: number};
type B = A & {b: string};
type C = B & {c: boolean};
declare function createA(): A;
declare function createB(): B;
declare function createC(): C;
function f(m: Map<string, B>, element: Base) {
m.set('1', createA()); // TS error (good)
m.set('1', createB());
m.set('1', createC());
mapGetOrCreate(m, '1', createA); // missing error!
mapGetOrCreate(m, '1', createB);
mapGetOrCreate(m, '1', createC);
}
I've found that adding another type parameter (V2
) to the function signature "fixes" the issue:
export function mapGetOrCreate2<K, V, V2 extends V>(map: Map<K, V>, key: K, valueFn: (key: K) => V2): V {
let value = map.get(key);
if (value === undefined) {
value = valueFn(key);
assert(value !== undefined);
map.set(key, value);
}
return value;
}
It's still unsound, but at least TypeScript can't automatically infer the types, which is a slight improvement.
Questions:
- Is there a better way to accomplish what I'm doing?
- Are there ways in which
mapGetOrCreate2
is worse than the original?
You are looking for noninferential type parameter usage as requested in microsoft/TypeScript#14829. There is no official support for this, but there are a number of techniques to get this effect, one of which is what you are using already.
Just to be clear for anyone who comes across this question later, the unsoundness here is that TypeScript allows
Map<K, U>
to be assignable toMap<K, T>
wheneverU
is assignable toT
:This is actually completely reasonable as long as you're only reading from the maps, but when you write to them you can end up getting in trouble:
This is just the way that TypeScript is; it has a set of useful features, such as object mutability, subtyping, aliasing, and method bivariance, that allow for increased developer productivity but can be used in unsafe ways. Anyway, see this SO answer for more details around such soundness issues.
There's really no way to completely prevent this; no matter what you do, even if you can harden
mapGetOrCreate()
, you can always use such aliasing to get around it:With that caveat in mind, though, what are the options for hardening
mapGetOrCreate()
?The real issue you're having with
mapGetOrCreate()
is TypeScript's generic type parameter inference algorithm. Let's boil downmapGetOrCreate()
to a functiong()
where we forget about the key typeK
(using juststring
). In the following call:The compiler infers that the type parameter
T
should be specified by the typeA
, becausevalueFn
returns anA
, and aMap<string, B>
is also considered a validMap<string, A>
.Ideally, you'd like the compiler to infer
T
frommap
alone, and then check thatvalueFn
is assignable to(key: string) => T
for thatT
. InvalueFn
you'd just like to useT
and not infer it.Put in other words, you're looking for noninferential type parameter usage, as requested in microsoft/TypeScript#14829. And as I said in the beginning, there's no official way of doing this.
Let's look at the unofficial ways:
One unofficial way is to use an additional type parameter
U
which is constrained to the original type parameterT
. SinceU
will be inferred separately fromT
, it will look "non-inferential" from the point of view ofT
. This is what you've done withmapGetOrCreate2
:Another unofficial way is to intersect the "non-inferential" locations with
{}
, which "lowers the priority" of that inference site. This is less reliable and only works for typesX
whereX & {}
does not narrowX
(soX
cannot haveundefined
ornull
in it), but it also works for this case:And the final way I know is to use the fact that evaluation of conditional types is deferred for as-yet unresolved type parameters. So
NoInfer<T>
will eventually evaluate toT
, but this will happen after type inference has occurred. And it also works here:All three of these approaches are workarounds and do not work in all cases. You can read through ms/TS#14829 if you're interested in details and discussion about it. My main point here is that if your technique works for your use cases, then it's probably fine and I don't know of any obviously superior technique.
The only way I'd say a modified version is worse than the original is that it is more complicated and requires more testing. The problem you are trying to avoid doesn't actually seem to come up incredibly often in practice (which is why method bivariance is part of the language); since you are actually running into the problem, then you should probably actually solve it, so the added complexity is worthwhile. But since such hardening is fundamentally impossible in the face of an unsound type system, there will quickly be a point of diminishing returns, after which is better to just embrace the unsoundness and write some more defensive runtime checking, and give up trying to carve out a territory of pure soundness.
Playground link to code