Create js interface for incrementally converting our jstest libraries to Typescripts

XMLWordPrintableJSON

    • Type: Improvement
    • Resolution: Unresolved
    • Priority: Major - P3
    • None
    • Affects Version/s: None
    • Component/s: None
    • None
    • Replication
    • Repl 2026-09-14
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Writing our jstest infra in Typescript, as opposed to Javsascript, has several advantages:

      • static type hinting, such as knowing if a function expects an object or an array of objects
      • documentation becomes:
        • static, hovering over a variable will show you its docstring
        • easier to find, you can just F12 on a variable and jump to its defintion, rather than grepping
        • more abundant; outlining which types a function expects is a form of documentation after all
      • autocomplete, which can help avoid typos and also inform you of which fields are expected/required
      • greater degree of expressiveness:
        • i.e. if you wanted to describe the type of the first parameter to the constructor of a class, but with only the object fields that map to numbers or strings that start with the word "foo", you can do that in Typescript
      • safety:
        • better, faster, smarter refactors: the aformentioned benefits can help you catch bugs/typos in your editor rather than at runtime
        • optionality becomes an explicit part of the API contract
        • rename/signature changes become machine-checkable across the codebase, instead of “grep and pray”
        • In the function ReplSetTest.status(timeout), the timeout parameter is completely ignored.
          • This is not documented and is likely a bug or contract violation, that could cause a false negative in a jstest.
          • I was able to find this error using a static typechecker. There are likely many more throughout the codebase.
      • AI:
        • LLMs (and humans alike) benefit heavily from static type information. Trying to understand which functions are called and when in new ReplSetTest() is an almost Sisyphean task, due to how much indirection there is.
        • less semantic drift across sessions:
          • If you ask an LLM to write you a jstest, and then start a new session, the new agent will have to discern which types all the variables and parameters are, and importantly: whether or not they were meant to be optional. The ability for an agent to "pass on" this static type info to a future agent is invaluable.

      Below is a proposition for incorporating jstests into our codebase that tackles the problems that blocked previous attempts:

      • incremental adoption:
        • we can change js files to Typescript one file at a time
        • we do not have to change previous imports to the former js file
      • zero workflow disruption:
        • if you do not need to modify one of these js->ts files, you will not notice any change, your workflow remains the same
        • if you do need to modify one of these files, you can just update the Typescript file and it will essentially "just work"
      • isolation:
        • this can start as a "repl-only" change; we likely could implement this without consulting another team, and if it doesn't work, we can easily roll it back
      • minimal runtime impact:
        • the described implementation should increase the runtime of our jstests on the order of a few hundred milliseconds at worst
      • zero additional evergreen build costs:
        • evergreen will not have to compile typescript to javascript

      Overview

      We choose one of the many existing methods for transpiling .ts files to (.js, .d.ts) files; for the sake of argument, let's use tsc. We write a Python script that takes in a Typescript filepath (i.e. mylibrary.ts), and transpiles it to mylibrary.js and mylibrary.d.ts. We take checksums of both the .js file and the .ts file. Now, for external users, this is enough; they can import the mylibrary.js file as they did before. For internal use, such as updating the Typescript library, we replace import "mylibrary.js" with tsimport("mylibrary.js") which will, at runtime, check to see if either file has been modified using the aformentioned checksums. If they have, and this run is on Evergreen, we immediately throw an exception. If this is a dev environment, tsimport will just run the bash script again, and then load in the file. Essentially, the Typescript library will be able to "compile itself" at runtime in development.

      Outline:

      (Note that this outline heavily uses pseudocode.)

      1. We create the following file ts-checksums.js
      /** jstests/checksums.js */
      export const checksums = {
        
        /** end */
      };
      
      1. We create the following function:
      import checksums from "./jschecksums.js";
      
      export function tsimport<Name extends string>(file: Name): Promise<typeof import(Name)> {
        if (
          (runCommand(`md5sum ./${file}.js`) 
            === checksums[file]["js"]
          ) &&
          (runCommand(`md5sum ./${file}.ts`)
            === checksums[file]["ts"]
          )
        ) {
          return import(file);
        }
      
        assert(
          runCommand("echo -n $TYPESCRIPT_AUTOBUILD") === "1", 
          "Typescript/Javascript mismatch error in production"
        );
      
        runCommand(`python3 ./compile_typescript_files.py ${file}`);
        return import(file);
      }
      
      1. We create the following compile_typescript_files.py:
      def main(args):
          args = parse(args)
          entries = parse_existing_checksum_entries(checksum_file)
      
          for raw_file in args.files:
              ts_file = normalize_to_ts_path(raw_file, repo_root)
      
              js_file = replace_suffix(ts_file, ".js")
              dts_file = replace_suffix(ts_file, ".d.ts")
              rel_ts = relative_path(ts_file, repo_root)
      
              temp_out = make_temp_dir()
              run([
                  "tsc",
                  "--declaration",
                  "--skipLibCheck",
                  "--target", "es2020",
                  "--module", "es2020",
                  "--moduleResolution", "node",
                  "--rootDir", repo_root,
                  "--outDir", temp_out,
                  ts_file,
              ])
      
              built_js = temp_out / replace_suffix(rel_ts, ".js")
              built_dts = temp_out / replace_suffix(rel_ts, ".d.ts")
      
              copy_file(built_js, js_file)
              copy_file(built_dts, dts_file)
              prepend_warning_banner(js_file, [
                  "WARNING!!!",
                  "This is a generated Javascript file.",
                  "Do NOT edit this file directly.",
                  f"Edit {basename(ts_file)} instead.",
              ])
      
              key = to_posix(remove_suffix(relative_path(ts_file, repo_root), ".ts"))
              entries[key] = {
                  "ts": md5(ts_file),
                  "js": md5(js_file),
              }
      
          write_file(checksum_file, render_js_object(
              export_name = "checksums",
              entries = entries,
              end_marker = "/** end */",
          ))
          print(f"updated {checksum_file}")
      

      Using a library written in Typescript

      as a consumer:

      If you don't plan on modifying the library, you can just import the file the same way you always have, and the .d.ts file will provide static autocomplete.

      /** jstests/mytest.js */
      import { flipCoins } from "jstests/lib/coinflip.js";
      
      assert(flipCoins(1000).heads < 1000);
      //                     👆 this would have autocomplete
      

      as a library author:

      You can simply replace the Javascript library with a Typescript one:

      /** jstests/libs/coinflip.ts */
      
      export type CoinFlipResult = { 
        heads: number, 
        tails: number 
      };
      export const flipCoins = (count: number): CoinFlipResult => {
        const res: CoinFlipResult = {heads: 0, tails: 0};
        for (let i = 0; i < count; ++i) {
          res[Math.random() > 0.5 ? "heads" : "tails"]++;
        }
        return res;
      }
      

      Then run the Python file on it once.

      // mylibrary.generated.js
      """
      WARNING!!!
      This is a generated Javascript file. Do NOT edit this file or else you will hit a runtime error.
      Instead, edit the file "./mylibrary.ts", and run `export TYPESCRIPT_AUTOBUILD=1`.
      """
      
      export const flipCoins = (count) => {
        const r = {heads: 0, tails: 0};
        for (let i = 0; i < count; ++i) 
          r[Math.random() > 0.5 ? "heads" : "tails"]++;
        return r;
      }
      
      /** jstests/libs/mytest.d.ts */
      
      export type CoinFlipResult = { heads: number, tails: number };
      declare const flipCoins: (count: number) => CoinFlipResult;
      
      /** jstests/checksums.js */
      export const checksums = {
        "mylibrary": {
          ts: "918ec9867a6395e375b9189383b25da4",
          js: "b80a7cbe92bdfdd9ddec7b9d7053de9a"
        }
        /** end */
      };
      

      Now, if you were to change the Typescript or the Javascript file, and then run jstests/libs/mytest.ts, you would ge the following error:

      Hash 722d2391c65eb001322250863ba51699 of libs/checksums.ts does not match the expected value 918ec9867a6395e375b9189383b25da4. If this is a dev environment, you need to run `python3 ./buildscripts/compile_typescript_files.py`. It is recommended to `export TYPESCRIPT_AUTOBUILD=1` and use `tsimport` instead of `import` to avoid this error.
      

      Now, after setting the TYPESCRIPT_AUTOBUILD environment variable, update the jstest to use `tsimport`:

      const flipCoins = tsimport("jstests/lib/coinflip.js");
      
      assert(flipCoins(1000).heads < 1000);
      

      Now, when you update the Typescript file, the tsimport function will handle the Typescript compilation for you. You don't have to run the Python script yourself anymore. And if, for some reason, it doesn't line up in your commit, then your Evergreen run will correctly fail.

            Assignee:
            Joseph Obaraye
            Reporter:
            Joseph Obaraye
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated: