diff --git a/scripts/ts-smoosh/README.md b/scripts/ts-smoosh/README.md
new file mode 100644
index 0000000000..e1c25b1d85
--- /dev/null
+++ b/scripts/ts-smoosh/README.md
@@ -0,0 +1,32 @@
+## TS Smoosh
+
+Combine type decls with related source files.
+
+
+# Use
+
+Given a JavaScript file (or list of files) like so:
+
+```
+$ node ts-smoosh/bin ./src/some-file.js
+```
+
+Will produce `.jsx` files using nearby `.d.ts` files.
+
+
+# Running Tests
+
+```
+$ node test
+```
+
+Test cases live in various directories in `./tests`. Tests run by comparing the output against golden master `.tsx` files. To update the masters, run `node ts-smoosh/bin ./test/0X-dir/somefile.js`.
+
+# Notes
+
+Here are articles and docs I that were helpful when writing this script.
+
+- [Using the Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API)
+- [Blog post about using TS's parser directly](https://medium.com/allenhwkim/how-to-parse-typescript-from-source-643387971f4e)
+- [TypeScript compiler APIs revisited](https://blog.scottlogic.com/2017/05/02/typescript-compiler-api-revisited.html)
+- [TypeScript API Playground on Glitch](https://typescript-api-playground.glitch.me/#example=Transformation%203)
diff --git a/scripts/ts-smoosh/bin.js b/scripts/ts-smoosh/bin.js
new file mode 100644
index 0000000000..680d97252c
--- /dev/null
+++ b/scripts/ts-smoosh/bin.js
@@ -0,0 +1,13 @@
+const { smoosh } = require("./smoosh");
+
+// Given an array of `.js` files, smooshes their .d.ts declarations and
+// produces a .tsx file.
+
+const without = (ending) => (fileName) =>
+ fileName.endsWith(ending)
+ ? fileName.substr(0, fileName.length - ending.length)
+ : fileName;
+
+const files = process.argv.slice(2);
+
+files.map(without(".js")).map(smoosh);
diff --git a/scripts/ts-smoosh/smoosh.js b/scripts/ts-smoosh/smoosh.js
new file mode 100644
index 0000000000..a06d7507dc
--- /dev/null
+++ b/scripts/ts-smoosh/smoosh.js
@@ -0,0 +1,182 @@
+const ts = require("typescript");
+const fs = require("fs");
+const path = require("path");
+
+/**
+ * Writes the result of smooshing to a file
+ */
+function smoosh(base) {
+ const smooshedSrc = returnSmooshed(base);
+ const outputFile = `./${base}.tsx`;
+ fs.writeFileSync(outputFile, smooshedSrc);
+}
+
+const declDoesntExist = { typeAliases: [], declarations: [], imports: [] };
+
+function returnSmooshed(base) {
+ const dtsFile = `${base}.d.ts`;
+ // TODO(btford): log a warning here?
+ const decls = fs.existsSync(dtsFile) ? parseDts(dtsFile) : declDoesntExist;
+
+ const jsFile = `${base}.js`;
+ const enrichedJsNode = enrichJs(jsFile, decls);
+
+ const outputFile = `${base}.tsx`;
+
+ const resultFile = ts.createSourceFile(
+ outputFile,
+ "",
+ ts.ScriptTarget.Latest,
+ false,
+ ts.ScriptKind.TSX
+ );
+ const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
+
+ const smooshedSrc = printer.printNode(
+ ts.EmitHint.Unspecified,
+ enrichedJsNode,
+ resultFile
+ );
+
+ return smooshedSrc;
+}
+
+function parseDts(dtsFile) {
+ const parsed = ts.createSourceFile(
+ dtsFile,
+ fs.readFileSync(dtsFile, "utf8"),
+ ts.ScriptTarget.Latest
+ );
+
+ // these are going on top
+ const typeAliases = [];
+ const declarations = {};
+ const imports = [];
+
+ const aggregateDecl = (statement) => {
+ // console.log(statement);
+ const kind = ts.SyntaxKind[statement.kind];
+
+ if (kind === "TypeAliasDeclaration") {
+ declarations[getIdentifierName(statement)] = statement.type;
+ typeAliases.push(statement);
+ return;
+ }
+ if (kind === "ImportDeclaration") {
+ return console.log("import...");
+ }
+
+ if (kind === "FirstStatement") {
+ return statement.declarationList.declarations.map(aggregateDecl);
+ }
+
+ if (!kind.endsWith("Declaration")) {
+ const message = `Unexpected statement kind "${kind}" in type definition file "${dtsFile}"`;
+ return console.warn(message);
+ } else {
+ declarations[getIdentifierName(statement)] = statement;
+ }
+ };
+
+ parsed.statements.forEach(aggregateDecl);
+
+ return { typeAliases, declarations, imports };
+}
+
+function enrichJs(jsFile, dts) {
+ const parsed = ts.createSourceFile(
+ jsFile,
+ fs.readFileSync(jsFile, "utf8"),
+ ts.ScriptTarget.Latest
+ );
+
+ const findSource = (node) => {
+
+ let typeSource = null;
+
+ // First, search for a jsdoc tag with the type, like:
+ // @type {typeof import('./b').Noop}
+ if (node.jsDoc) {
+ const typeTag = node.jsDoc[0].tags.find(
+ (tag) => tag.tagName.escapedText === "type"
+ );
+ if (typeTag) {
+ const fileName =
+ typeTag.typeExpression.type.argument.literal.text;
+ const identifier =
+ typeTag.typeExpression.type.qualifier.escapedText;
+ const dir = path.dirname(jsFile);
+ const fullPath = path.resolve(dir, fileName + ".d.ts");
+ const importedDts = parseDts(fullPath);
+ const importedType = importedDts.declarations[identifier];
+ if (!importedType) {
+ console.warn(
+ `Could not find ${identifier} in ${fullPath} while trying to smoosh ${jsFile}`
+ );
+ return node;
+ }
+ typeSource = importedType;
+ }
+ }
+
+ // Second, use the d.ts file with the same name as this file.
+ if (!typeSource) {
+ typeSource = dts.declarations[getIdentifierName(node)];
+ }
+
+ return typeSource;
+ }
+
+ const transformer = (context) => (rootNode) => {
+ function visit(node) {
+ const kind = ts.SyntaxKind[node.kind];
+ if (kind.endsWith("Declaration")) {
+ if (kind === "FunctionDeclaration") {
+ const typeSource = findSource(node);
+ if (typeSource) {
+ return ts.factory.updateFunctionDeclaration(
+ node,
+ node.decorators,
+ node.modifiers,
+ node.asteriskToken,
+ node.name,
+ typeSource.typeParameters,
+ typeSource.parameters,
+ typeSource.type,
+ node.body
+ );
+ }
+ return node;
+ } else if (kind === 'VariableDeclaration') {
+ const typeSource = findSource(node);
+ if (typeSource) {
+ return ts.factory.updateVariableDeclaration(
+ node,
+ node.name,
+ node.exclamationToken,
+ typeSource.type,
+ node.initializer
+ );
+ }
+ return node;
+ }
+
+ return node;
+ }
+
+ return ts.visitEachChild(node, visit, context);
+ }
+ return ts.visitNode(rootNode, visit);
+ };
+
+ return ts.transform(parsed, [transformer]).transformed[0];
+}
+
+function getIdentifierName(node) {
+ return node.name.escapedText;
+}
+
+module.exports = {
+ smoosh,
+ returnSmooshed,
+};
diff --git a/scripts/ts-smoosh/test.js b/scripts/ts-smoosh/test.js
new file mode 100644
index 0000000000..72789a9f05
--- /dev/null
+++ b/scripts/ts-smoosh/test.js
@@ -0,0 +1,27 @@
+const fs = require("fs");
+const assert = require("assert").strict;
+const path = require("path");
+
+const { returnSmooshed } = require("./smoosh");
+
+const args = process.argv.slice(2);
+
+const dirs = args.length > 0 ? args : fs.readdirSync("./tests").map(f => `./tests/${f}`);
+
+dirs.forEach((dir) => {
+ const files = fs.readdirSync(dir);
+
+ files
+ .filter((file) => file.endsWith(".js"))
+ .map((file) => file.substr(0, file.length - 3))
+ .forEach((file) => {
+ const fullFile = path.resolve(dir, file);
+ console.log(`Testing ${fullFile}.js`);
+ const smooshed = returnSmooshed(fullFile);
+
+ const target = fs.readFileSync(fullFile + ".tsx", "utf8");
+ assert.equal(smooshed, target);
+ });
+});
+
+console.log("Done");
diff --git a/scripts/ts-smoosh/tests/01-functions/a.d.ts b/scripts/ts-smoosh/tests/01-functions/a.d.ts
new file mode 100644
index 0000000000..4cc6c20337
--- /dev/null
+++ b/scripts/ts-smoosh/tests/01-functions/a.d.ts
@@ -0,0 +1,6 @@
+export function foo(
+ bar: string,
+ baz: number
+): {boo: number};
+
+export function typeParams(a: A, b: B): B;
diff --git a/scripts/ts-smoosh/tests/01-functions/a.js b/scripts/ts-smoosh/tests/01-functions/a.js
new file mode 100644
index 0000000000..65cccbb1d6
--- /dev/null
+++ b/scripts/ts-smoosh/tests/01-functions/a.js
@@ -0,0 +1,15 @@
+/**
+ * A function with a JSDoc type import that matches its name
+ * @type {typeof import('./a').foo}
+ */
+ export function foo(
+ bar,
+ baz
+) {
+ return {boo: baz}
+}
+
+// A function with no type import
+export function typeParams(a, b) {
+ return a + b;
+}
diff --git a/scripts/ts-smoosh/tests/01-functions/a.tsx b/scripts/ts-smoosh/tests/01-functions/a.tsx
new file mode 100644
index 0000000000..168cf84717
--- /dev/null
+++ b/scripts/ts-smoosh/tests/01-functions/a.tsx
@@ -0,0 +1,13 @@
+/**
+ * A function with a JSDoc type import that matches its name
+ * @type {typeof import('./a').foo}
+ */
+export function foo(bar: string, baz: number): {
+ boo: number;
+} {
+ return { boo: baz };
+}
+// A function with no type import
+export function typeParams(a: A, b: B): B {
+ return a + b;
+}
diff --git a/scripts/ts-smoosh/tests/02-imports/b.d.ts b/scripts/ts-smoosh/tests/02-imports/b.d.ts
new file mode 100644
index 0000000000..38ca8b3eaa
--- /dev/null
+++ b/scripts/ts-smoosh/tests/02-imports/b.d.ts
@@ -0,0 +1,2 @@
+
+export type MyFn = (a: number, b: number) => number
diff --git a/scripts/ts-smoosh/tests/02-imports/c.js b/scripts/ts-smoosh/tests/02-imports/c.js
new file mode 100644
index 0000000000..396b6c2008
--- /dev/null
+++ b/scripts/ts-smoosh/tests/02-imports/c.js
@@ -0,0 +1,9 @@
+
+/**
+ * A function with a JSDoc type import that is different from its name
+ * @type {typeof import('./b').MyFn}
+ */
+function c(a, b) {
+ console.log('haha hi')
+ return a + b;
+}
diff --git a/scripts/ts-smoosh/tests/02-imports/c.tsx b/scripts/ts-smoosh/tests/02-imports/c.tsx
new file mode 100644
index 0000000000..674be451ae
--- /dev/null
+++ b/scripts/ts-smoosh/tests/02-imports/c.tsx
@@ -0,0 +1,8 @@
+/**
+ * A function with a JSDoc type import that is different from its name
+ * @type {typeof import('./b').MyFn}
+ */
+function c(a: number, b: number): number {
+ console.log("haha hi");
+ return a + b;
+}
diff --git a/scripts/ts-smoosh/tests/03-const/e.d.ts b/scripts/ts-smoosh/tests/03-const/e.d.ts
new file mode 100644
index 0000000000..f46e533529
--- /dev/null
+++ b/scripts/ts-smoosh/tests/03-const/e.d.ts
@@ -0,0 +1,4 @@
+// hi
+export const x: number;
+
+export const add: (a: number, b: number) => number;
diff --git a/scripts/ts-smoosh/tests/03-const/e.js b/scripts/ts-smoosh/tests/03-const/e.js
new file mode 100644
index 0000000000..d0ab5ccc16
--- /dev/null
+++ b/scripts/ts-smoosh/tests/03-const/e.js
@@ -0,0 +1,3 @@
+export const x = 3;
+
+export const add = (a, b) => a + b;
diff --git a/scripts/ts-smoosh/tests/03-const/e.tsx b/scripts/ts-smoosh/tests/03-const/e.tsx
new file mode 100644
index 0000000000..8886106ea2
--- /dev/null
+++ b/scripts/ts-smoosh/tests/03-const/e.tsx
@@ -0,0 +1,2 @@
+export const x: number = 3;
+export const add: (a: number, b: number) => number = (a, b) => a + b;