Shepmaster
Shepmaster

Reputation: 431599

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.

Upvotes: 14

Views: 2870

Answers (4)

1800 INFORMATION
1800 INFORMATION

Reputation: 135393

I created a fork from Mike Patricks version which has a few advantages

Export "abstract class" instead of type or interface from the loader.

Better developer experience

  • Does not require to import some concrete class/variable
  • Will give correct typings on first build
  • Does require a globals.d.ts to be created

https://github.com/gwynjudd/ts-generating-loader

Upvotes: 0

Thomas
Thomas

Reputation: 182000

I know it's an old question, but I recently ran into similar problems, so it's still relevant.

One workaround is to add declare module "*.api"; to your index.d.ts file. But this has the huge drawback of losing type safety, because everything in a shorthand module declaration has type any. So then you might as well not generate TypeScript interfaces to begin with.

I managed to solve it using some voodoo that I don't fully understand, but it seems to be working.

package.json

Note that I'm using the latest versions of everything at the time of writing, and ts-loader instead of the deprecated awesome-typescript-loader.

{
  "name": "so-example",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },
  "dependencies": {
    "ts-loader": "^9.2.8",
    "typescript": "^4.6.3",
    "webpack": "^5.70.0",
    "webpack-cli": "^4.9.2"
  }
}

webpack.config.ts

The need for appendTsSuffixTo is well-documented, and it's necessary, but not sufficient.

I discovered the scarcely documented resolveModuleName option for ts-loader. By poking around in the ts-loader code and observing inputs and outputs to the function, I managed to cobble together the custom resolver function you see below. We have to append a .ts extension to the resolvedFileName to trick the TypeScript compiler into accepting the file.

Notice that we need to use this ts-loader configuration for both rules, so I extracted it into a variable.

const path = require('path');

const tsLoader = {
  loader: "ts-loader",
  options: {
    appendTsSuffixTo: [/\.api$/],
    resolveModuleName: (moduleName, containingFile, compilerOptions, compilerHost, parentResolver) => {
      if (/\.api$/.test(moduleName)) {
        const fileName = path.resolve(path.dirname(containingFile), moduleName);
        return {
          resolvedModule: {
            originalFileName: fileName,
            resolvedFileName: fileName + '.ts',
            resolvedModule: undefined,
            isExternalLibraryImport: false,
          },
          failedLookupLocations: [],
        };
      }
      return parentResolver(moduleName, containingFile, compilerOptions, compilerHost);
    },
  },
};

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: [tsLoader, "my-own-loader"],
      },
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [tsLoader],
      },
    ]
  },
};

tsconfig.json

I don't know why this is required, but without it, I get TS18002: The 'files' list in config file 'tsconfig.json' is empty..

{}

my-own-loader.js

I'm adding a value to show that we retain type safety.

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

export const DUMMY_VALUE: DummyContent = {
    name: "Jon Snow",
    age: 24,
}
`;
};

index.ts

Notice that I'm importing the file including the .api extension. It's probably possible to change the resolveModuleName function to work without an extension, but I didn't bother. I actually like to see the extension here as a clue that something special is going on.

import { DUMMY_VALUE } from './example.api';

console.log(DUMMY_VALUE.name);
console.log(DUMMY_VALUE.youKnowNothing); // Does not compile

Upvotes: 1

Mike Patrick
Mike Patrick

Reputation: 11006

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.

Upvotes: 11

Mark Dreyer
Mark Dreyer

Reputation: 198

If your API follows the swagger spec, you can use the npm package swagger-ts-generator to generate TypeScript files from it:

Swagger TypeScript code generator

Node module to generate TypeScript code for Angular (2 and above) based on Webapi meta data in Swagger v2 format.

Basically, you give it the swagger URL and it generates TypeScript. The examples are for Gulp, but they should port over to WebPack fairly well:

var swagger = {
    url: 'http://petstore.swagger.io/v2/swagger.json',
    //url: 'http://127.0.0.1/ZIB.WebApi.v2/swagger/docs/v1',
    swaggerFile: folders.swaggerFolder + files.swaggerJson,
    swaggerFolder: folders.swaggerFolder,
    swaggerTSGeneratorOptions: {
        modelFolder: folders.srcWebapiFolder,
        enumTSFile: folders.srcWebapiFolder + 'enums.ts',
        enumLanguageFiles: [
            folders.srcLanguagesFolder + 'nl.json',
            folders.srcLanguagesFolder + 'en.json',
        ],
        modelModuleName: 'webapi.models',
        enumModuleName: 'webapi.enums',
        enumRef: './enums',
        namespacePrefixesToRemove: [
        ],
        typeNameSuffixesToRemove: [
        ]
    }
}

Upvotes: 0

Related Questions