Skip to content

Commit

Permalink
#61, StringStringMap use case, part 2.
Browse files Browse the repository at this point in the history
  • Loading branch information
ajvincent committed Mar 22, 2024
1 parent d76646a commit 81e5871
Show file tree
Hide file tree
Showing 3 changed files with 239 additions and 12 deletions.
222 changes: 210 additions & 12 deletions docs/use-cases/TwoKeyedMap.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ Very often, I find myself needing a map with two keys for each value. Instead o

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`](../guides/MemberedTypeToClass.md)

## Design
## Design requirements

- There will be a private `#hashMap: Map<string, V>`, which every method will forward operations to.
- We must hash two keys into one, and we must be able to extract two keys from every hashed key.
- Otherwise, it should resemble the `Map` class very closely.

## Starting source code

Expand Down Expand Up @@ -33,26 +37,220 @@ This lays the foundation for `StringStringMap`, but obviously it's nowhere near

## Getting the `Map` interfaces from TypeScript

## Rewriting the `Map` type for the `implements` attribute
Because the `Map` is a built-in type for TypeScript, we have to look up interfaces from TypeScript's own files.

```typescript
/* 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:

## Rewriting the `Map` type in a `TypeMembersMap`
```typescript
import fs from "fs/promises";
import path from "path";

## Creating the existing class structure
import {
type SourceFile
} from "ts-morph";

import {
NodeWithStructures,
project,
projectDir,
} from "./sharedProject.js";

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]);
}
```

## What do the interfaces specify?

Ideally with something even as complex as our `StringStringMap` design above, I'd start with an existing source file. I _could_ build the `ClassDeclarationImpl` from scratch, but why would I?
So before this, I asked to log the interfaces, stringified. Some snippets:

```typescript
const sourceFile = buildSourceFile(`
interface StringStringKey {
// ...
`);
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]`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toStringTag).

## Creating a `TypeMembersMap` from the interface nodes

This is relatively easy, and is necessary for `MemberedTypeToClass` to work:

```typescript
// 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
]);
});
```

## Analyzing the interfaces for type-to-class requirements

const classDecl = getTypeAugmentedStructure(
sourceFile.getClassOrThrow("StringStringMap"),
There are a few properties, but mostly methods:

)
_Properties_

- `readonly size: number`
- `readonly [Symbol.toStringTag]: string`

_Methods_

- clear
- delete
- forEach
- get
- has
- set
- `[Symbol.iterator]`
- entries
- keys
- values

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" |

| Method name | (header) | `size` | `toStringTag` | (footer) |
|-------------|----------|--------|---------------|----------|
| clear | | | | |
| delete | | | | |
| forEach | | | | |
| get | | | | |
| has | | | | |
| `[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:

```typescript
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 comment out this line from earlier:

```typescript
readonly #hashMap = new Map<string, V>;
```

Instead, I need to create a new property signature:

```typescript
{
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.

```typescript
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 | | | | |
| `[Symbol.Iterator]` | | | | |
| entries | | | | |
| keys | | | | |
| values | | | | |
| constructor(...?) | | | | |
| get size() | | | | |

## Rewriting the `Map` type in the `TypeMembersMap`

## Creating the existing class structure

## Using `MemberedTypeToClass` to build the rest of the class

### Statements
27 changes: 27 additions & 0 deletions use-cases/build/StringStringMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ import path from "path";
import {
type InterfaceDeclaration,
type SourceFile,
StructureKind,
} from "ts-morph";

import {
PropertySignatureImpl,
TypeMembersMap,
getTypeAugmentedStructure,
VoidTypeNodeToTypeStructureConsole,
} from "#stage_two/snapshot/source/exports.js";

import {
distDir,
project,
Expand Down Expand Up @@ -42,7 +50,9 @@ export default class StringStringMap<V> {
return [firstKey, secondKey];
}
/*
readonly #hashMap = new Map<string, V>;
*/
}
`.trim()
);
Expand All @@ -55,6 +65,23 @@ export default class StringStringMap<V> {
console.log(node.print());
}

// 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
]);
});
{
const hashMap = new PropertySignatureImpl("#hashMap");
hashMap.isReadonly = true;
typeMembers.addMembers([hashMap]);
}
typeMembers.convertPropertyToAccessors("size", true, false);

await moduleFile.save();
return Promise.resolve();
}
2 changes: 2 additions & 0 deletions use-cases/dist/StringStringMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ export default class StringStringMap<V> {
return [firstKey, secondKey];
}

/*
readonly #hashMap = new Map<string, V>;
*/
}

0 comments on commit 81e5871

Please sign in to comment.