Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make an NPM module with globally accessible types

Tags:

npm

typescript

Keywords: use types from TypeScript module without importing, publishing a package with types only, tell TypeScript to seek types in an NPM module.


I want to publish an NPM module that contains globally accessible types, much like lib.d.ts.

What structure should the module have and how do I include it in another project?

If making the types globally visible is just too hard, requiring it with <reference/> would be enough, but that didn't work when I tried.


In the project where I want to use the types, I've got a src folder containing all the source code and bin folder which contains the output of tsc.

The module containing types can have virtually any structure, I don't really care as long as it works.


So far I've tried many, many combinations including exporting the types, declareing the types, export declareing the types, putting them to .ts or to .d.ts file, moving them around the package's folder inside node_modules, importing them, <reference/>ing them, putting them to rootDirs… But nothing worked. And the lack of good documentation on this also didn't help.

like image 280
m93a Avatar asked Apr 12 '19 21:04

m93a


2 Answers

I had to solve this for my logging library, winston-jsonl-logger. It augments the global scope with a global variable called logger. I agree that this is one of the hardest (if not the hardest) problem in TypeScript, not least because of lack of sufficient documentation. In this example, I create a library that uses both globally-visible ('script') and module-visible ('module') types. To clarify that official terminology:

In TypeScript, just as in ECMAScript 2015, any file containing a top-level import or export is considered a module. Conversely, a file without any top-level import or export declarations is treated as a script whose contents are available in the global scope (and therefore to modules as well).

Directory structure

My src folder is transpiled into dist. test is ignored from transpilation.

It is imperative that you typings are named index.d.ts and are nested in a folder whose name is the same as your project (which strictly is probably the name specified in package.json). That's what structure typeRoots will be looking for.

.
├── README.md
├── dist
│   ├── Logger.d.ts
│   ├── Logger.js
│   ├── Logger.js.map
│   ├── initLoggers.d.ts
│   ├── initLoggers.js
│   └── initLoggers.js.map
├── package-lock.json
├── package.json
├── src
│   ├── Logger.ts
│   └── initLoggers.ts
├── test
│   └── index.ts
├── tsconfig.json
└── typings
    └── winston-jsonl-logger
        └── index.d.ts

'script' typings

Script typings are those that lack a top-level import or export. They will be visible globally across projects that consume them.

Of course, as they can't use top-level import declarations, they are limited in how descriptive they can be; you may often see a lot of any used here. This is a problem I'm trying to get solved in my own question.

// typings/index.d.ts
declare namespace NodeJS {
    export interface Global {
        logger?: any;
        log?: any;
        logInfo?: any;
    }
}

If you use logger in the global scope, it will be typed as any now.

'module' typings

Module typings can use top-level import or export, but they will only be seen if the module gets imported into the project. i.e. they are not visible globally across the project.

// initLoggers.ts
import {Logger} from "./Logger";
import {LogEntry, Logger as WinstonLogger} from "winston";

// Now we can be more descriptive about the global typings
declare global {
    const logger: Logger;
    // LogEntry's interface: { level: string, message: string, data?: any }
    function log(entry: LogEntry): WinstonLogger;
    function logInfo(message: string, data?: any): WinstonLogger;
}

export function initLoggers(){
    global.logger = new Logger();
    global.log = logger.log.bind(logger);
    global.logInfo = (message: string, data?: any) => {
        return logger.log({ level: "info", message, data });
    }
}

If you use logger in the global scope, it will still be typed as any, but at least global.logger will have proper typings.

To guarantee that these types are made visible across your project my-project, make sure that my-project imports this file from the winston-jsonl-logger; I do it at my app's entrypoint.

package.json

I didn't use the typings or types field (maybe specifying "typings": "typings/winston-jsonl-logger/index.d.ts" would have meant that packages don't have to explicitly declare the path to my typings; I don't know), but I did make sure to distribute my folder of typings.

{
  "name": "winston-jsonl-logger",
  "version": "0.5.3",
  "description": "TypeScript JSONL logger.",
  "main": "dist/Logger.js",
  "files": [
    "dist",
    "typings"
  ],
  "devDependencies": {
    "@types/logform": "1.2.0",
    "@types/node": ">=9.6.21",
    "ts-node": "7.0.1",
    "typescript": "3.1.1"
  },
  "dependencies": {
    "winston": "3.2.0",
    "winston-daily-rotate-file": "3.6.0",
    "winston-elasticsearch": "0.7.4"
  }
}

Omitted fields: repository, keywords, author, license, homepage, publishConfig, and scripts; otherwise, that's everything.

tsconfig.json

For the lib itself

Nothing special. Just your standard tsc --init defaults.

For projects consuming the lib

Just make sure that you add a typeRoots looks like this:

{
  "compilerOptions": {
    // ...All your current fields, but also:
    "typeRoots": [
      "node_modules/@types",
      "node_modules/winston-jsonl-logger/typings/winston-jsonl-logger"
    ]
  }
}

If you're using ts-node

There are further gotchas here. By default, ts-node ignores script typings and only imports descendents of the entry-level import (the reason for this is speed/efficiency). You can force it to resolve imports just like tsc does by setting the environment variable: TS_NODE_FILES=true. Yes, it will run tests slower, but on the other hand, it'll work at all.

If you're using ts-node via commandline, declare the TS_NODE_FILES environment variable to be true. I also had to declare TS_NODE_CACHE to be false, because of an inexplicable cache bug in ts-node (version 7.0.1 – may still be an issue) when it's resolving imports/dependencies.

TS_NODE_FILES="true" TS_NODE_CACHE="false" TS_NODE_PROJECT="./tsconfigs/base.json" /usr/bin/nodejs --require ts-node/register --inspect=127.0.0.1:9231 src/index.ts --myCustomArg="hello"

I'm normally using ts-node because I'm testing with Mocha. Here's how I pass environment variables to ts-node from Mocha:

// mocha.env.js

/* From: https://github.com/mochajs/mocha/issues/185#issuecomment-321566188
 * Via mocha.opts, add `--require mocha.env` in order to easily set up environment variables for tests.
 *
 * This can theoretically be made into a TypeScript file instead, but it seemed to not set the env variable when I tried;
 * perhaps it failed to respect the order of the --require declarations. */
process.env.TS_NODE_FILES = "true"; // Force ts-node to use TypeScript module resolution in order to implictly crawl ambient d.ts files
process.env.TS_NODE_CACHE = "false"; // If anything ever goes wrong with module resolution, it's usually the cache; set to false for production, or upon any errors!

Hope this helps!

like image 74
Jamie Birch Avatar answered Oct 31 '22 01:10

Jamie Birch


Similar to the other responders here, I have also spent a fair amount of time trying to get this right 😓. My use case is slightly different and I found an alternative way based on what I've seen other libs do.

Posting in case this is useful to anyone else.


My goal is slightly different than OP's: I wanted to publish global interfaces types and for the downstream users of my lib to have readily available whenever they are writing types, but I'm not trying to augment window or global, just publish global ambient types like React does (e.g. you don't have to import React to use React.ComponentType when writing types).

Here's how I did it:

  1. Create the global types you want to publish in some ambient types file. I call mine ambient.d.ts and I put it at the root of my project folder. This file is meant to be published alongside the dist folder.
  2. Create a new types entry-point (/index.d.ts) that re-exports your compiled entry-point (./dist/index.d.ts) and include in there a triple-slash reference (/// <reference types="./ambient" />) to your ambient types file. Also ensure that your tsconfig.json's include option has ambient.d.ts.
  3. When you publish your lib, tell your users to create a .d.ts file at the root of their project folder containing a triple-slash reference to your lib. Many create-*-app starters already create this file. For example, create-next-app creates a next-env.d.ts file that already contains triple-slash references. You could tell your users to augment that.

Directory structure:

.
├── README.md
├── package-lock.json
├── package.json
├── src
│   ├── index.ts
│   └── initLoggers.ts
├── dist
│   ├── index.d.ts   <-- your compiled index.d.ts file
│   ├── index.js
│   ├── index.js.map
│   ├── foo.d.ts
│   ├── foo.js
├── ambient.d.ts     <-- write global types here
├── index.d.ts       <-- new types entry point
├── tsconfig.json

You'd publish package.json, ambient.d.ts, index.d.ts, and everything inside of dist.

/package.json:

{
  // ...
  "types": "./index.d.ts", // specify the new entry types entry-point
  // ...
}

/index.d.ts and tsconfig.json (step 2):

// this is the new entry-point for your types
// use the triple-slash reference to bring in your ambient types
/// <reference types="./ambient" />

// re-export your compiled types
export * from './dist';
{
  "compilerOptions": { /* ... */ },
  "include": ["./src", "./ambient.d.ts"]
}

For your downstream users (step 3):

Tell them to create a blah.d.ts file and add a triple-slash reference to your lib. As stated above, next.js already has this file and it's called next-env.d.ts. You could tell your users to augment that or create a new *.d.ts file.

/// <reference types="next" />
/// <reference types="next/types/global" />
// 👇👇👇
/// <reference types="your-published-lib-name" />
// 👆👆👆

👋 Alternatively, as other answers suggested, you could tell your users add your lib to the typeRoots compiler options in tsconfig.json but I prefer the triple-slash reference instead since it doesn't change the default compiler option and it's what I've seen other libs like next.js do.

like image 39
Rico Kahler Avatar answered Oct 31 '22 01:10

Rico Kahler