Reputation: 2109
I was working on translating some C# code where they used dictionaries of Vector2 to string:
I made a simple test case (see here on .NET fiddle) to try to convert to JS:
using System;
using System.Collections.Generic;
Dictionary<Vector2, string> hd = new()
{
{new(0,0), "Zero" },
{new(0,1), "{0, 1}"},
{new(1,0), "{1, 0}"},
{new(1,1), "{1, 1}"}
};
foreach (var (key, value) in hd)
Console.WriteLine($"Key: {{{key.x}, {key.y}}}. Value: {value}");
public record Vector2 (double x, double y);
I tried to translate to typescript and I wanted to make a general dictionary type that would work on any type where you could put a function that would translate to and from a value that you could put in a JS Map
.
I made a hasher type for the converter to a mappable type:
interface Hasher<T, Hash>
{
GetHashCode(value: T ): Hash;
GetValue (hash:Hash): T;
}
and a Vector2 type:
interface Vector2 { x:number; y:number; }
I made an abstract type as follows:
abstract class Dictionary<TKey, TValue, Hash>
{
['constructor']: typeof Dictionary & Hasher<TKey, Hash>;
impl: Map<Hash, TValue> = new Map();
}
and made a simple add and iterator:
Add(key: TKey, value: TValue)
{
this.impl.set(this.constructor.GetHashCode(key), value);
}
*[Symbol.iterator](): Iterator<[TKey, TValue]>
{
for (let [key, value] of this.impl)
yield [this.constructor.GetValue(key), value];
}
I made a function to convert a Vector2 to and from a hex string:
static Vector2 = class<TValue> extends Dictionary<Vector2, TValue, string>
{
static GetHashCode(value: Vector2): string
{
let buffer = new ArrayBuffer(16);
let arr = new Float64Array(buffer);
arr[0] = value.x;
arr[1] = value.y;
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
}
static GetValue(hash: string): Vector2
{
let buffer = new ArrayBuffer(16);
let u8arr = new Uint8Array(buffer);
for (let n = 0; n < 16; n++) u8arr[n] = +('0x'+hash.substr(n*2, 2));
let arr = new Float64Array(buffer);
return {x: arr[0], y: arr[1]};
}
}
and I wanted to add some type checks to ensure that my Dictionary implementations were implementing the required methods:
// Type Checks:
{
const Dictionary$string: Hasher<string, string> = Dictionary.string ;
const Dictionary$int: Hasher<number, number> = Dictionary.int ;
const Dictionary$Vector2:Hasher<Vector2, string> = Dictionary.Vector2;
}
My question was whether there is a better way to go about doing these type checks (maybe one that doesn't just generate redundant JS). This is the best way I could find.
Upvotes: 1
Views: 702
Reputation: 328302
The answer to your question as asked is probably "anything else you do to verify the types of those static
properties will be more involved than what you're already doing, so you might as well keep doing that".
TypeScript doesn't have a simple way to verify that a value is assignable to some type without generally widening it to that type. You'd like to say something like
static Vector2 = class <TValue> extends Dictionary<Vector2, TValue, string> {
/* snip */
} verify Hasher<Vector2, string>;
where verify
is some type-system-only alternative to a type assertion which doesn't affect the inferred type of the Vector2
property, but will produce a compiler error if that inferred type is not assignable to Hasher<Vector2, string>
. There is a longstanding open issue at microsoft/TypeScript#7481 asking for such a feature, but for now it doesn't exist.
You can implement something yourself which works like it, but it has a (minor) runtime component and is complicated by lack of language support for partial type parameter inference of the sort asked for in microsoft/TypeScript#26242. Here's one possible approach:
static Vector2 = verify(null! as Hasher<Vector2, string>,
class <TValue> extends Dictionary<Vector2, TValue, string> {
/* snip */
}
);
where verify
looks like
const verify = <T, U extends T>(dummy: T, val: U) => val;
So the compiler will check the type of val
to make sure it's assignable to the type of dummy
, and it returns val
without widening it. Conceptually there's no reason why the compiler should need the dummy
value, but you want the compiler to infer U
while letting you specify T
, and there's no direct support for that.
Anyway, you could define and call verify()
instead of creating Dictionary$Vector2
, but I don't know that it's worth it.
So that's the basic answer.
As I alluded to in the comments, though, I don't think I'd try to implement Dictionary
the way you have. The issues I spot:
what you are calling a "hash" code is really an identifier. Hash codes are not meant to identify a piece of data; you use hash codes to quickly split things up into buckets, with the acknowledgement that more than one thing might end up in the same bucket. For Dictionary
it would be very bad if different keys ever had the same hash code. Instead, the intent here is that your identifier function should define what key equality means: two keys are equal if and only if they produce the same identifier.
Instead of allowing the identifier to be of any type, I would be inclined to pick string
and stick with it. Whatever you choose needs to be comparable with ===
, since that's more or less how Map
key equality works.
I don't see much use for GetValue
and it could even be harmful. TypeScript uses a structural type system in which you are allowed to extend Vector2
with extra properties. A value of interface Vector2WithCheese extends Vector2 { cheese: true }
is also a value of type Vector2
; if I put a Vector2WithCheese
into my dictionary, I expect that Vector2WithCheese
to come out; not some freshly-created Vector2
. Even without subtyping, breaking reference equality on the key could surprise users and doesn't seem to buy you much. Instead, I'd suggest holding both the original key and the value in the inner Map
.
I'm a bit wary of code that declares the constructor
property of a class; I suppose this is to work around the lack of strongly-typed constructors, as per microsoft/TypeScript#3841. I don't know how helpful it is to require that the constructor itself have certain static properties, especially abstract
static properties which are not really supported, as per microsoft/TypeScript#34516. There might be some benefit to ensuring that a whole class will calculate key equality the same way, but I don't know that it's worth the complexity around strongly typing the static side of the class. Instead, I'd probably just pass the key-identifier function as a parameter to the class constructor and allow for the possibility that two instances of Dictionary
might compare keys in different ways.
Then instead of storing the subclasses of Dictionary
as static properties of Dictionary
itself, I'd probably just make regular subclasses where the key identifier function is set a particular way. Once you do this, the need for type checking the subclasses is gone.
The code could look something like this:
class Dict<K, V> {
private map = new Map<string, [K, V]>()
constructor(public toIdString: (k: K) => string, entries?: Iterable<[K, V]>) {
if (entries) {
for (const [k, v] of entries) {
this.set(k, v);
}
}
}
set(k: K, v: V) {
this.map.set(this.toIdString(k), [k, v]);
return this;
}
get(k: K): V | undefined {
return this.map.get(this.toIdString(k))?.[1]
}
[Symbol.iterator](): Iterator<[K, V]> {
return this.map.values();
}
}
and
class Vector2Dict<V> extends Dict<Vector2, V> {
constructor(entries?: Iterable<[Vector2, V]>) {
super(v => "" + v.x + "," + v.y, entries);
}
}
And you can verify that it behaves as desired:
const hd = new Vector2Dict<string>([
[{ x: 0, y: 0 }, "Zero"],
[{ x: 0, y: 1 }, "{0, 1}"],
[{ x: 1, y: 0 }, "{1, 0}"],
[{ x: 1, y: 1 }, "{1, 1}"]
]);
for (const [key, value] of hd) {
console.log(`Key: {${key.x}, ${key.y}}. Value: ${value}`);
}
/*
Key: {0, 0}. Value: Zero
Key: {0, 1}. Value: {0, 1}
Key: {1, 0}. Value: {1, 0}
Key: {1, 1}. Value: {1, 1}
*/
console.log(hd.get({ x: 0, y: 0 })?.toUpperCase()); // ZERO
Upvotes: 1