typescriptwebpackloader

Is it possible to generate Typescript interfaces from files with a webpack loader?


I am attempting to create a webpack loader that converts a file containing a description of API data structures into a set of TypeScript interfaces.

In my concrete case, the file is JSON, but this should be ultimately irrelevant — the file is only a shared source of data describing the interaction between web application backend(s) and frontend(s). In my MCVE below, you can see that the JSON file contains an empty object to underscore how the type and contents of the file do not matter to the problem.

My current attempt reports two errors (I assume the second is caused by the first):

[at-loader]: Child process failed to process the request:  Error: Could not find file: '/private/tmp/ts-loader/example.api'.
ERROR in ./example.api
Module build failed: Error: Final loader didn't return a Buffer or String

How can I generate TypeScript code using a webpack loader?

package.json

{
  "name": "so-example",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },
  "dependencies": {
    "awesome-typescript-loader": "^3.2.3",
    "typescript": "^2.6.1",
    "webpack": "^3.8.1"
  }
}

webpack.config.js

const path = require('path');

module.exports = {
  entry: './index.ts',
  output: {
    filename: 'output.js',
  },
  resolveLoader: {
    alias: {
      'my-own-loader': path.resolve(__dirname, "my-own-loader.js"),
    },
  },
  module: {
    rules: [
      {
        test: /\.api$/,
        exclude: /node_modules/,
        use: ["awesome-typescript-loader", "my-own-loader"],
      },
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        loader: "awesome-typescript-loader",
      },
    ]
  },
};

my-own-loader.js

module.exports = function(source) {
  return `
interface DummyContent {
    name: string;
    age?: number;
}
`;
};

index.ts

import * as foo from './example';

console.log(foo);

example.api

{}

I recognize that there are other code generation techniques. For example, I could convert my JSON files to TypeScript with some build tool and check them in. I'm looking for a more dynamic solution.


my-own-loader.js does not export json but string.

That's correct, much like loading an image file doesn't always export binary data but sometimes outputs a JavaScript data structure representing metadata about the image.

Why you need to define the typescript interfaces from json file? Would the interfaces be used for typescript compilation?

Yes. I want to import a file that describes my API data structures and automatically generate corresponding TypeScript interfaces. Having a shared file allows the frontend(s) and backend(s) to always agree on what data will be present.


Solution

  • First off, kudos for providing an MCVE. This is a really interesting question. The code I worked with to put this answer together is based on said MCVE, and is available here.

    Missing File?

    This is a most unhelpful error message indeed. The file is clearly in that location, but TypeScript will refuse to load anything that doesn't have an acceptable extension.

    This error is essentially hiding the real error, which is

    TS6054: File 'c:/path/to/project/example.api' has unsupported extension. The only supported extensions are '.ts', '.tsx', '.d.ts', '.js', '.jsx'.
    

    This can be verified by hacking into typescript.js, and manually adding the file. It's ugly, as detective work often is (starts at line 95141 in v2.6.1):

    for (var _i = 0, rootFileNames_1 = rootFileNames; _i < rootFileNames_1.length; _i++) {
        var fileName = rootFileNames_1[_i];
        this.createEntry(fileName, ts.toPath(fileName, this.currentDirectory, getCanonicalFileName));
    }
    this.createEntry("c:/path/to/project/example.api", ts.toPath("c:/path/to/project/example.api", this.currentDirectory, getCanonicalFileName));
    

    Conceptually, you're just passing a string between loaders, but it turns out the file name is important here.

    A possible fix

    I didn't see a way to do this with awesome-typescript-loader, but if you're willing to use ts-loader instead, you can certainly generate TypeScript from files with arbitrary extensions, compile that TypeScript, and inject it into your output.js.

    ts-loader has an appendTsSuffixTo option, that can be used to work around the well-known file extension pain. Your webpack config might look something like this if you went that route:

    rules: [
      {
        test: /\.api$/,
        exclude: /node_modules/,
        use: [
          { loader: "ts-loader" },
          { loader: "my-own-loader" }
        ]
      },
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        loader: "ts-loader",
        options: {
          appendTsSuffixTo: [/\.api$/]
        }
      }
    ]
    

    Note on interfaces and DX

    Interfaces are erased by the compiler. This can be demonstrated by running tsc against something like this

    interface DummyContent {
        name: string;
        age?: number;
    }
    

    vs. this

    interface DummyContent {
        name: string;
        age?: number;
    }
    
    class DummyClass {
        printMessage = () => {
            console.log("message");
        }
    }
    
    var dummy = new DummyClass();
    dummy.printMessage();
    

    In order to provide a nice developer experience, you may need to write these interfaces to a file in the dev environment only. You don't need to write them out for a production build, and you don't need (or want) to check them into version control.

    Developers probably need to have them written out so their IDE has something to sink its teeth into. You might add *.api.ts to .gitignore, and keep them out of the repository, but I suspect they'll need to exist in the developers' workspaces.

    For example, in my sample repo, a new developer would have to run npm install (as usual) and npm run build (to generate the interfaces in their local environment) to get rid of all their red squigglies.