The following leads to an error with this.#map
:
type MapType<K, V> = K extends object ? (Map<K, V> | WeakMap<K,V>) : Map<K,V>
export function getOrSet<K, V> (map: MapType<K, V>, key: K, create: (k: K) => V): V {
if (!map.has(key)) { map.set(key, create(key)) }
return map.get(key)
}
export class Select<T extends object> {
#map = new WeakMap<T, any>()
observable (item: T) { return getOrSet(this.#map, item, () => 'any') }
}
But this is incorrect because T extends object
and the error goes away when we change it to WeakMap<object, any>
.
Is there any fix/workaround here?
Noting that using an overload or K & object
moves the error into the getOrSet
:
TypeScript isn't really able to do much analysis on generic types, especially conditional types that depend on generics. Generic conditional types like MapType<T, any>
are essentially deferred by the compiler. It doesn't use the fact that T
is constrained to object
to partially evaluate it. There is a longstanding open feature request at microsoft/TypeScript#23132 to change this, but so far it's not part of the language. Until and unless that happens, you'll have to work around it.
One simple workaround, given your example code, is to widen T
to object
when you call getOrSet()
:
export class Select<T extends object> {
#map = new WeakMap<T, any>()
observable(item: T) { return getOrSet<object, any>(this.#map, item, () => 'any') }
}
But if you need things to remain generic, this isn't very useful.
One approach which often helps is to forget about trying to constrain map
to either Map<K, V>
or WeakMap<K, V>
, but just write out MapType<K, V>
to capture only the functionality you actually care about. If the implementation of getOrSet()
only calls get()
and set()
(or has()
) then you can declare MapType<K, V>
so that it only includes get()
and set()
(or has()
):
interface MapType<K, V> {
get(k: K): V | undefined;
set(k: K, v: V): MapType<K, V>;
// has(k: K): boolean;
}
Nothing there needs to care about whether K
is object
-like or not, because that really isn't important for what you're doing. Then the implementation works as desired:
export function getOrSet<K, V>(map: MapType<K, V>, key: K, create: (k: K) => V): V {
let v = map.get(key);
if (typeof v === "undefined") {
v = create(key);
map.set(key, v);
}
return v;
}
And now you don't have anything conditional going on at all. When you call getOrSet()
, TypeScript sees this.#map
is being of type MapType<T, any>
:
export class Select<T extends object> {
#map = new WeakMap<T, any>()
observable(item: T) { return getOrSet(this.#map, item, () => 'any') }
}
And you can see that both Map
and WeakMap
are supported by MapType
naturally:
const k = { a: "abc" };
const m = new WeakMap<{ a: string }, { b: number }>();
const v = getOrSet(m, k, ({ a }) => ({ b: a.length })); // okay
console.log(v);
const m2 = new Map<{ a: string }, { b: number }>();
const v2 = getOrSet(m2, k, ({ a }) => ({ b: a.length })); // okay
console.log(v2);
Note, you could decide to overload the function to give it exactly the call signatures you want to support, assuming you really don't want to accept things other than Map
or WeakMap
:
export function getOrSet<K extends object, V>(map: Map<K, V> | WeakMap<K, V>, key: K, create: (k: K) => V): V;
export function getOrSet<K, V>(map: Map<K, V>, key: K, create: (k: K) => V): V;
export function getOrSet<K, V>(map: MapType<K, V>, key: K, create: (k: K) => V) {
let v = map.get(key);
if (typeof v === "undefined") {
v = create(key);
map.set(key, v);
}
return v;
}
But I don't know that this buys you very much. Overload implementations are not checked strictly against each call signature (see microsoft/TypeScript#13235) so you need to be careful. Overloads have other caveats, so unless the added complexity is important to you, I'd avoid them.