Very often, I find myself needing a map with two keys for each value. Instead of writing Map<string, Map<string, object>>, though, I want a simple class I generate from existing Map objects and the Map interface.
To do this, I’ll have to bootstrap from existing Map, hashing the keys into a combined key… but I also have to generate a new type and then build a class for it, using MemberedTypeToClass.
#hashMap: Map<string, V>, which every method will forward operations to.Map class very closely.interface StringStringKey {
readonly firstKey: string,
readonly secondKey: string
}
class StringStringMap<V> {
static #hashKeys(firstKey: string, secondKey: string): string {
return JSON.stringify({firstKey, secondKey});
}
static #parseKeys(hashedKey: string): [string, string]
{
const { firstKey, secondKey } = JSON.parse(hashedKey) as StringStringKey;
return [firstKey, secondKey];
}
readonly #hashMap = new Map<string, V>;
}
This lays the foundation for StringStringMap, but obviously it’s nowhere near complete.
Map interfaces from TypeScriptBecause the Map is a built-in type for TypeScript, we have to look up interfaces from TypeScript’s own files.
/* What are we dealing with? */
const MapInterfaceNodes = getTypeScriptNodes<InterfaceDeclaration>(
sourceFile => sourceFile.getInterfaces().filter(ifc => ifc.getName() === "Map")
).map(entry => entry[1]);
for (const node of MapInterfaceNodes) {
console.log(node.print());
}
What is this getTypeScriptNodes function? I wrote this utility function in a separate module:
import fs from "fs/promises";
import path from "path";
import {
type SourceFile
} from "ts-morph";
import {
NodeWithStructures,
project,
projectDir,
} from "./sharedProject.js";
/*
export interface NodeWithStructures extends Node {
getStructure(): Structures;
}
*/
const TYPESCRIPT_LIBS = path.join(projectDir, "node_modules/typescript/lib");
const fileNames = (await fs.readdir(TYPESCRIPT_LIBS)).filter(f => /^lib\..*\.d.ts$/.test(f)).map(f => path.join(TYPESCRIPT_LIBS, f));
const sourceFiles: readonly SourceFile[] = project.addSourceFilesAtPaths(fileNames);
export default function getTypeScriptNodes<
NodeKind extends NodeWithStructures
>
(
callback: (sourceFile: SourceFile) => NodeKind[]
): [string, NodeKind][]
{
return sourceFiles.map(
sourceFile => processSourceFile(sourceFile, callback)
).flat();
}
function processSourceFile<
NodeKind extends NodeWithStructures
>
(
sourceFile: SourceFile,
callback: (sourceFile: SourceFile) => NodeKind[]
): [string, NodeKind][]
{
const nodes = callback(sourceFile);
const pathToSourceFile = sourceFile.getFilePath();
return nodes.map(node => [pathToSourceFile, node]);
}
So before this, I asked to log the interfaces, stringified. Some snippets:
interface Map<K, V> {
// ...
forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void;
// ...
get(key: K): V | undefined;
// ...
}
interface Map<K, V> {
[Symbol.iterator](): IterableIterator<[
K,
V
]>;
// ...
keys(): IterableIterator<K>;
// ...
}
interface Map<K, V> {
readonly [Symbol.toStringTag]: string;
}
The first of these interfaces is not too bad. K doesn’t appear as a stand-alone type parameter, only as a type for a parameter key. So I can replace key: K with firstKey: string, secondKey: string (two parameters).
The second interface is more challenging. [Symbol.iterator] isn’t so bad: we can replace K with string, string in the return type. But keys is a genuine problem. IterableIterator is a type taking only one type parameter. To support this, I need to turn the return type into IterableIterator<[string, string]>.
For the third interface, I just have to look up the documentation on Symbol.toStringTag.
TypeMembersMap from the interface nodesThis is relatively easy, and is necessary for MemberedTypeToClass to work:
// Create the initial type members map
const typeMembers = new TypeMembersMap();
MapInterfaceNodes.forEach(node => {
const structure = getTypeAugmentedStructure(
node, VoidTypeNodeToTypeStructureConsole, true, StructureKind.Interface
).rootStructure;
typeMembers.addMembers([
// no getters or setters to worry about
...structure.properties,
...structure.methods
]);
});
There are a few properties, but mostly methods:
readonly size: numberreadonly [Symbol.toStringTag]: string[Symbol.iterator]I need to think of these as tables, for method statements. Properties have their own table for initializers:
| Property name | Initializer |
|---|---|
| size | 0 |
[Symbol.toStringTag] |
“StringStringMap” |
The methods table primarily deals with properties (the columns) and the statemented nodes (rows).
| Method name | (header) | size |
toStringTag |
(footer) |
|---|---|---|---|---|
| clear | ||||
| delete | ||||
| forEach | ||||
| get | ||||
| has | ||||
| set | ||||
[Symbol.Iterator] |
||||
| entries | ||||
| keys | ||||
| values |
There are two key pieces missing from these tables. First, I know each of these methods will refer to this.#hashMap, but there’s no interface property for private members. (The reason for this should be obvious.) I specified the property directly in the class stub earlier, but this now appears to be a mistake.
The second missing piece is “constructor”. The interfaces don’t tell you this, but the Map class has a constructor which takes an optional argument:
interface MapConstructor {
new (): Map<any, any>;
new <K, V>(entries?: readonly (readonly [K, V])[] | null): Map<K, V>;
readonly prototype: Map<any, any>;
}
declare var Map: MapConstructor;
Our StringStringMap class will emulate this, but for now we just need to be aware of it and adjust our method-property statements table accordingly.
To solve the first, I need to create a property signature for the property, and add the signature to the type members map. To save myself some trouble, I’m going to remove this line from earlier:
readonly #hashMap = new Map<string, V>;
Instead, I need to create a new property signature:
{
const hashMap = new PropertySignatureImpl("#hashMap");
hashMap.isReadonly = true;
typeMembers.addMembers([hashMap]);
}
There’s one more problem we have to solve before we can move on. The size property should actually be a getter, returning our private hash map’s size.
typeMembers.convertPropertyToAccessors("size", true, false);
With this, I can adjust the above tables:
| Property name | Initializer |
|---|---|
[Symbol.toStringTag] |
“StringStringMap” |
#hashMap |
new Map<string, V> |
| Method name | (header) | toStringTag |
#hashMap |
(footer) |
|---|---|---|---|---|
| clear | ||||
| delete | ||||
| forEach | ||||
| get | ||||
| has | ||||
| set | ||||
[Symbol.Iterator] |
||||
| entries | ||||
| keys | ||||
| values | ||||
| constructor(…?) | ||||
| get size() |
TypeMembersMapMost methods need some adjustment. So I’m going to call arrayOfKind to get the method signatures. (Method signatures in interfaces are the type definitions for the equivalent method declarations in classes.)
{
const methods: readonly MethodSignatureImpl[] = typeMembers.arrayOfKind(StructureKind.MethodSignature);
for (const method of methods) {
// ...
}
}
keys methodI’ve already called out the keys method as needing special attention. What I want is a return type of IterableIterator<[string, string]>. What I have is a return type of IterableIterator<K>.
Here, I’m going to use NodeJS’s assert function for a couple reasons. (1) I wish to assert the existing definition of keys from TypeScript doesn’t change in the future. (The odds of this are not quite infinitesimal, but if it does change, I want to know.) (2) The assert function allows TypeScript to know the shape of a particular type structure.
It helps to look up the type structures table. I also need to be familiar with the following properties of MethodSignatureImpl objects:
For the keys case, I’ll skip the research and present the solution:
if (method.name === "keys") {
const { returnTypeStructure } = method;
assert.equal(returnTypeStructure?.kind, TypeStructureKind.TypeArgumented, "Expected a type-argumented type.");
assert.equal(returnTypeStructure.objectType, LiteralTypeStructureImpl.get("IterableIterator"), "Expected an IterableIterator");
assert.equal(returnTypeStructure.childTypes.length, 1);
assert.equal(returnTypeStructure.childTypes[0], LiteralTypeStructureImpl.get("K"));
returnTypeStructure.childTypes[0] = new TupleTypeStructureImpl([
LiteralTypeStructureImpl.get("string"),
LiteralTypeStructureImpl.get("string"),
]);
continue;
}
Note I probably could’ve gotten away with replacing the K with { firstKey: string, secondKey: string }.
The [Symbol.iterator]() and entries() methods already specify IterableIterator<[K, V]>. I want IterableIterator<[string, string, V]>. I think, from the keys example above, the solution for this should be obvious.
The values method I don’t need to adjust at all: IterableIterator<V>.
forEach methodHere the adjustments are to the arguments of forEach - and they’re a little deeper for the first argument, which is a callback function. So we’ll be dealing with the parameters array property of the method signature.
if (method.name === "forEach") {
// forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void;
const callbackFn: ParameterDeclarationImpl = method.parameters[0];
const { typeStructure } = callbackFn;
assert.equal(typeStructure?.kind, TypeStructureKind.Function, "the callback should be a function");
const firstKeyParam = new ParameterTypeStructureImpl("firstKey", LiteralTypeStructureImpl.get("string"));
const secondKeyParam = new ParameterTypeStructureImpl("secondKey", LiteralTypeStructureImpl.get("string"));
typeStructure.parameters.splice(1, 1, firstKeyParam, secondKeyParam);
typeStructure.parameters[3].typeStructure = new TypeArgumentedTypeStructureImpl(
LiteralTypeStructureImpl.get("StringStringMap"),
[LiteralTypeStructureImpl.get("V")]
);
continue;
}
At this point, the only methods we haven’t touched are those with simple parameters, where one of the parameters may be the key parameter. A simple parameter replacement should suffice.
const { parameters } = method;
const keyIndex = parameters.findIndex(param => param.name === "key");
if (keyIndex > -1) {
const firstParam = new ParameterDeclarationImpl("firstKey");
firstParam.typeStructure = LiteralTypeStructureImpl.get("string");
const secondParam = new ParameterDeclarationImpl("secondKey");
secondParam.typeStructure = LiteralTypeStructureImpl.get("string");
parameters.splice(keyIndex, 1, firstParam, secondParam);
}
We should print the existing type members as an interface, to make sure we’re on track. Looking over the TypeMembersMap, there is a moveMembersToType method, but the documentation says “clear this map” after doing so. That could be a problem.
Fortunately, there’s also a clone() method. We’ll also need to feed an interface declaration to ts-morph to use its printing capability.
const interfaceTemp = new InterfaceDeclarationImpl("StringStringMapInterface");
typeMembers.clone().moveMembersToType(interfaceTemp);
const interfaceNode: InterfaceDeclaration = moduleFile.addInterface(interfaceTemp);
console.log(interfaceNode.print());
interfaceNode.remove();
The resulting output:
interface StringStringMapInterface {
/** @returns the number of elements in the Map. */
get size(): number;
readonly [Symbol.toStringTag]: string;
readonly #hashMap;
clear(): void;
/** @returns true if an element in the Map existed and has been removed, or false if the element does not exist. */
delete(firstKey: string, secondKey: string): boolean;
/**
* Executes a provided function once per each key/value pair in the Map, in insertion order.
*/
forEach(callbackfn: (value: V, firstKey: string, secondKey: string, map: StringStringMap) => void, thisArg?: any): void;
/**
* Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.
* @returns Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
*/
get(firstKey: string, secondKey: string): V | undefined;
/** @returns boolean indicating whether an element with the specified key exists or not. */
has(firstKey: string, secondKey: string): boolean;
/**
* Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.
*/
set(firstKey: string, secondKey: string, value: V): this;
/** Returns an iterable of entries in the map. */
[Symbol.iterator](): IterableIterator<[
string,
string,
V
]>;
/**
* Returns an iterable of key, value pairs for every entry in the map.
*/
entries(): IterableIterator<[
string,
string,
V
]>;
/**
* Returns an iterable of keys in the map
*/
keys(): IterableIterator<[
string,
string
]>;
/**
* Returns an iterable of values in the map
*/
values(): IterableIterator<V>;
}
The readonly #hashMap; part is clearly illegal for an interface. But that’s okay, because this is just a check point. I can ignore that. The rest of this pseudo-interface is correct.
We are ready to start on the hard part.
MemberedTypeToClass to build the classNow that we know the shape of what we’re trying to implement, we can fill out the rest of our properties / methods tables.
| Property name | Initializer |
|---|---|
[Symbol.toStringTag] |
“StringStringMap” |
#hashMap |
new Map<string, V> |
| Method name | (header) | toStringTag |
#hashMap |
(footer) |
|---|---|---|---|---|
| clear | ✓ | |||
| delete | ✓ | ✓ | ||
| forEach | ✓ | |||
| get | ✓ | ✓ | ✓ | |
| has | ✓ | ✓ | ✓ | |
| set | ✓ | ✓ | ✓ | |
[Symbol.Iterator] |
✓ | |||
| entries | ✓ | |||
| keys | ✓ | |||
| values | ✓ | |||
| constructor(…?) | ✓ | |||
| get size() | ✓ |
These require a bit of explanation, and the decisions are partially arbitrary.
(header) traps for this.(footer) traps for this.entries is a special case, in that it can just call return this[Symbol.iterator]().
For the keys iterator, I am making a conscious decision to keep statements in the #hashMap property column. This is because I know I will be iterating over the hash map, and using generator syntax. I follow the same pattern for [Symbol.iterator].
For the values iterator, I simply need to return this.#hashMap.values(). The size getter is equally simple.
You may have noticed toStringTag didn’t have any checkmarks in its column here. This property has zero impact on the methods, so I don’t need to do anything with it.
Following the MemberedTypeToClass guide, there are certain steps we need to take:
const typeToClass = new MemberedTypeToClass();
typeToClass.importFromTypeMembersMap(false, typeMembers);
{
const param = new ParameterDeclarationImpl("entries");
param.typeStructure = parseLiteralType(`[string, string, V][]`);
param.initializer = "[]";
typeToClass.constructorParameters.push(param);
}
typeToClass.isGeneratorCallback = {
isGenerator: function(isStatic, methodName): boolean {
return methodName === "[Symbol.iterator]" || methodName === "keys";
}
};
typeToClass.defineStatementsByPurpose("main body", false);
I use parseLiteralType here because it’s more human-readable. It’s less efficient to be sure, but I’ve already illustrated building type structures manually. Since this is the last complex type I expect to define directly, it’s fine here.
Our tables above indicate what statement getters we need. First, the toStringTag property. We need to initialize its value in the class. This means an object which implements ClassStatmentsGetter and PropertyInitialzierGetter.
const toStringTagGetter: ClassStatementsGetter & PropertyInitializerGetter = {
keyword: "Symbol.toStringTag",
supportsStatementsFlags: ClassSupportsStatementsFlags.PropertyInitializer,
filterPropertyInitializer: function (key: MemberedStatementsKey): boolean {
return key.fieldKey === "[Symbol.toStringTag]";
},
getPropertyInitializer: function (key: MemberedStatementsKey): string {
void(key);
return `"StringStringMap"`;
}
};
We will need a property initializer for the #hashMap property as well. I’ll skip past the boilerplate here.
The iterators are complex enough to go next. Our table above indicates for keys, values, entries and Symbol.iterator, we need the ClassBodyStatementsGetter and ClassTailStatementsGetter interfaces.
const iteratorStatements: ClassStatementsGetter & ClassBodyStatementsGetter & ClassTailStatementsGetter = {
keyword: "iterators",
supportsStatementsFlags: ClassSupportsStatementsFlags.BodyStatements | ClassSupportsStatementsFlags.TailStatements,
filterBodyStatements: function(key: MemberedStatementsKey): boolean {
if (key.fieldKey !== "#hashMap")
return false;
return key.statementGroupKey === "keys" || key.statementGroupKey === "[Symbol.iterator]";
},
getBodyStatements: function(key: MemberedStatementsKey): string[] {
return [`
for (const x of this.#hashMap${key.statementGroupKey === "keys" ? "." + key.statementGroupKey : key.statementGroupKey}()) {
const [ firstKey, secondKey ] = StringStringMap.#parseKeys(${key.fieldKey === "keys" ? "x" : "x[0]"});
yield [firstKey, secondKey${key.statementGroupKey === "[Symbol.iterator]" ? ", x[1]" : ""}];
}
`.trim()];
},
filterTailStatements: function(key: MemberedStatementsKey): boolean {
return key.statementGroupKey === "values" || key.statementGroupKey === "entries";
},
getTailStatements: function(key: MemberedStatementsKey): string[] {
if (key.statementGroupKey === "values") {
return [`return this.#hashMap.values()`];
}
return [`return this[Symbol.iterator]();`]
}
};
Then there’s forEach(). There’s no elegance here, just brute force.
const forEachStatements: ClassStatementsGetter & ClassBodyStatementsGetter = {
keyword: "forEach",
supportsStatementsFlags: ClassSupportsStatementsFlags.BodyStatements,
filterBodyStatements: function(key: MemberedStatementsKey): boolean {
return key.fieldKey === "#hashMap" && key.statementGroupKey === "forEach";
},
getBodyStatements: function(key: MemberedStatementsKey): string[] {
void(key);
return [`
this.#hashMap.forEach((value, key): void => {
const [ firstKey, secondKey ] = StringStringMap.#parseKeys(key);
callbackfn.call(thisArg, value, firstKey, secondKey, this);
}, thisArg);
`.trim()];
}
};
At this point we’ve dealt with the hard cases, and we can start doing simpler forwarding code. These all depend on #hashMap, but only some of them have the firstKey and secondKey parameters. This time, instead of relying on the statement group key, I’ll rely on the method’s type signature.
const forwardToMapMethods: (
ClassStatementsGetter & ClassHeadStatementsGetter &
ClassBodyStatementsGetter & ClassTailStatementsGetter
) = {
keyword: "forward-to-map",
supportsStatementsFlags:
ClassSupportsStatementsFlags.HeadStatements |
ClassSupportsStatementsFlags.BodyStatements |
ClassSupportsStatementsFlags.TailStatements,
filterHeadStatements: function(key: MemberedStatementsKey): boolean {
if (key.groupType?.kind !== StructureKind.MethodSignature)
return false;
return Boolean(key.groupType.parameters.find(param => param.name === "firstKey"));
},
getHeadStatements: function(key: MemberedStatementsKey): string[] {
void(key);
return [`
const key = StringStringMap.#hashKeys(firstKey, secondKey);
`.trim()];
},
filterBodyStatements: function(key: MemberedStatementsKey): boolean {
return this.filterHeadStatements(key) && key.fieldKey === "#hashMap";
},
getBodyStatements: function(key: MemberedStatementsKey): string[] {
return [`
${
key.statementGroupKey !== "set" ? "const rv = " : ""
}this.#hashMap.${key.statementGroupKey}(key${
key.statementGroupKey === "set" ? ", value" : ""
});
`.trim()]
},
filterTailStatements: function(key: MemberedStatementsKey): boolean {
return this.filterHeadStatements(key);
},
getTailStatements: function(key: MemberedStatementsKey): string[] {
if (key.statementGroupKey === "set")
return [`return this;`];
return [`return rv;`];
}
};
You may be wondering why set has special treatment inside the getBodyStatements() and getTailStatements() methods. The reason is Map.set() returns the Map instance, and StringStringMap.prototype.set() returns this as well.
There’s only three class members left: clear and get size(), and the constructor.
const noKeyMembers: ClassStatementsGetter & ClassTailStatementsGetter & ConstructorBodyStatementsGetter = {
keyword: "no-key-members",
supportsStatementsFlags: ClassSupportsStatementsFlags.TailStatements | ClassSupportsStatementsFlags.ConstructorBodyStatements,
filterTailStatements: function(key: MemberedStatementsKey): boolean {
return key.statementGroupKey === "get size" || key.statementGroupKey === "clear";
},
getTailStatements: function(key: MemberedStatementsKey): string[] {
if (key.statementGroupKey === "get size")
return [`return this.#hashMap.size;`];
return [
`return this.#hashMap.clear();
`.trim()];
},
filterCtorBodyStatements: function(key: MemberedStatementsKey): boolean {
return key.fieldKey === "#hashMap";
},
getCtorBodyStatements: function(key: MemberedStatementsKey): string[] {
void(key);
return [`
entries.forEach(([firstKey, secondKey, value]) => this.set(firstKey, secondKey, value));
`.trim()];
}
};
We have all these statement getters. Now we need to register them.
typeToClass.addStatementGetters(0, [
toStringTagGetter, hashMapInitializer, iteratorStatements,
forEachStatements, forwardToMapMethods, noKeyMembers,
]);
// getting the class with its static methods defined
const classDecl = moduleFile.getClassOrThrow("StringStringMap");
const classStructure = ClassDeclarationImpl.clone(classDecl.getStructure());
// build the class!
const classMembers = typeToClass.buildClassMembersMap();
classMembers.moveMembersToClass(classStructure);
/*
This doesn't work due to a bug in ts-morph:
classDecl.set(classStructure);
Instead, I do this, which is almost as good:
*/
classDecl.remove();
moduleFile.addClass(classStructure);
await moduleFile.save();
First, the StringStringMap class is potentially useful, but limited. I run into situations quite often where I have a two- or three-part key I need to use in a map or weak-map scenario, and the keys aren’t always strings. This is one reason why I created the composite-collection package.
Second, this class, while probably usable or close to it, is not polished for production use.
any type in the forEach methodBoth of these concerns are beside the point. You can build a realistic class from nothing but the interfaces and existing classes, using ts-morph. The ts-morph-structures package’s tools make it somewhat easier… but there’s still a fair bit of complexity to writing (and maintaining) code which generates other code. There’s really no getting around that.