Yet another math core prototype for a possible future of mathjs
Go to file
2024-10-11 13:37:07 +02:00
src chore: inject the reflected type information inside the .ship() occurrences 2024-10-11 13:37:07 +02:00
tools chore: inject the reflected type information inside the .ship() occurrences 2024-10-11 13:37:07 +02:00
.gitignore chore: make the build script work on Windows 2024-10-11 09:34:16 +02:00
LICENSE Initial commit 2024-09-21 17:55:51 +00:00
package.json5 chore: add reflected type information to the .js files in ./build 2024-10-11 12:15:17 +02:00
pnpm-lock.yaml feat: add type reflection via ts-macros 2024-09-29 22:10:09 -07:00
README.md feat: add type reflection via ts-macros 2024-09-29 22:10:09 -07:00
tsconfig.json feat: add type reflection via ts-macros 2024-09-29 22:10:09 -07:00

math5

Yet another math core prototype for a possible future of mathjs.

This project is a revision of the typocomath prototype (which was the fourth in the series picomath, pocomath, typocomath, hence the current name math5), preparing for an initial implementation of the Dispatcher engine to assemble and run the methods specified in TypeScript.

Motivations for the refactor:

  1. I observed that the .d.ts files that were being generated as a result of the TypeScript compilation step did not contain sufficient type information to see what each implementation/factory for each operation of the resulting math module would do. This lack suggested that the TypeScript definitions of the implementations and factories were not actually being fully typechecked.

  2. I felt that there was still a significant amount of redundancy in the implementation files. For example, in typocomath/src/numbers/arithmetic, it reiterates for every arithmetic operation "foo" that "foo" implements the number signature for the "foo" operation. It seemed like it would be preferable to specify that this module is for the "number" type fewer times, and not have to mention the operation name for each operation twice.

  3. I did not love the creation of aliased operation names like "addReal" that were actually implementations of the operation "add" but with different signatures. I found that mechanism confusing.

You can verify that the new code compiles and generates implementation information by cloning the repository and then running pnpm install, npx ts-patch install, and then pnpm go.

Outcomes of the refactor, corresponding to the motivations:

1a. You can browse the generated build/**/*.d.ts files to see that they now contain full, detailed type information on every implementation and factory, including the exact types of the dependencies.

1b. The TypeScript compiler now correctly detected (which it had not done in typocomath) that the intermediate real square roots in the complex sqrt implementation might be used even though they had come out to NaN. This outcome is direct evidence that the TypeScript compiler is now type-checking more strictly, so we are getting greater value from using TypeScript to specify the operations' behavior. (In addition, it led to adding the isnan predicate so that the code would compile.)

  1. There is less repeated information. For example, math5/src/numbers/arithmetic only mentions number twice, and only mentions each operation once.

  2. Implementations/factories are now only exported under their actual operation names, just with different signatures specified. The default name of a dependency is the name of the operation, but when you have dependencies on a given operation with different signatures, you can name the dependency arbitrarily and then specify which operation it is an instance of.

Other potential advantages of the refactor:

  • Assembling the implementation specifications (the main task of which is resolving and injecting dependencies) into a running math engine could potentially work by parsing the .d.ts files as generated; we would not necessarily need to instrument the typescript code with macros to generate the additional information needed to correctly assemble the factories.

Some disadvantages of the refactor:

  • The presentation of the code is slightly more verbose in places. The primary cause of this is the switch to a "builder" interface for collecting implementations/factories, as advised by TypeScript guru jcalz in order to get narrower type inference as desired. So for example in src/Complex/arithmetic, every factory (a "dependent implementation", as opposed to an "independent" one that has no dependencies) is wrapped in its own call to dependent(dependencySpecifiers, factories). And that whole chain of dependent calls has to be kicked off with a call to implementations() and wrapped up with a call to ship(). Of course, the names of those functions could be changed, but it appears that currently there is no way to avoid these wrappers if we want TypeScript to do narrow type inference/typechecking.

  • When one module is providing multiple implementations for the same operation, but with different signatures, it must export multiple of these bundles of implementations generated with an implementation(). ... .ship() seequence, because each bundle can only contain an operation once. The names of these bundles are arbitrary. I think this artificial division is a little cumbersome/confusing. See src/Complex/arithmetic for an example, in which there is a default export with the "common" signatures for operations, and a mixed export with variants for add and divide that operate on a Complex and an ordinary number.

  • The notation for the desired signatures for dependencies can still be a bit arcane/cumbersome. It's very simple when the desired dependency consists of the common signature for that operation. But for more unusual situations, it can become intricate. For example, in src/Complex/arithmetic, the absquare (absolute value squared, an operation needed to define division and square root) factory needs as a dependency the addition operation on the return type of the absquare operation on the base type of the Complex number. This has ended up being specified as:

add: {sig: commonSignature<'add', CommonReturn<'absquare', T>>()}

which is a bit of a mouthful. It's possible that better utilities for expressing desired signatures could be devised; I'd want to wait until we had collected a larger number of use cases before trying to design them. (If this absquare case is essentially a one-off, it doesn't really matter if it is a bit elaborate.)

Looking ahead

If this format is pursued, the next step would be to extract type information that's needed to assemble the factories into working operations by injecting the proper dependencies. There are two possible sources for this information: (1) parsing the .d.ts files generated by tsc during the build, or (2) generating strings encoding the types using the $$typeToString facility of the ts-macros package.

To help pick between the two, this version is instrumented with $$typeToString to record the type of all of the exported implementation objects, so that one can simply compare the output of pnpm go with the .d.ts files.

At a first look, some features relevant to the choice are:

A) With ts-macros (method 2), the type information it generates is available immediately upon importing the JavaScript files generated by the tsc build step. With method 1, we would need to insert an additional build step after tsc that parses the .d.ts files and produces one or more small JavaScript modules (or possibly JSON files) that contain the type information in a usable format.

B) On the other hand, the type specifications in the d.ts files appear to have many more type definitions resolved and expanded out for us, making them easier to read, parse, and use in the operations-assembly process.

C) With ts-macros we have a couple of additional package dependencies and an additional installation step (npx ts-patch install). For either method, we will have a TypeScript type parser module that we will need to write and maintain.

Given these points, on balance at the moment I would lean ever so slightly toward just parsing the .d.ts files -- it seems like less trouble overall despite the additional build step -- but I could totally go either way.