How to have absolute import paths in a library project?
Asked Answered
O

2

32

I have a library with a workspace containing two projects, one for the library itself and one for a test application.

├── projects
    ├── midi-app
    └── midi-lib

In the workspace tsconfig.json file I configured some @app and @lib paths:

"paths": {
  "@app/*": ["projects/midi-app/src/app/*"],
  "@lib/*": ["projects/midi-lib/src/lib/*"],
  "midi-lib": [
    "dist/midi-lib"
  ],
  "midi-lib/*": [
    "dist/midi-lib/*"
  ]
}

There is a projects/midi-lib/tsconfig.lib.json file which extends on the above tsconfig.json file:

"extends": "../../tsconfig.json",

There is a public-api.ts file which contains:

export * from './lib/midi-lib.module';

I can use this library with the test application just fine.

But when I try using it in another client application, in another workspace, imported as a Node module, I get many errors on the unknown paths Can't resolve '@lib/...'

How to express the library paths so that they are exposed in a client application ? Or how to translate the library paths when packaging the library ?

As a side question, I wonder why the extends is not done the other way around. Why is it not the tsconfig.json file that extends on the projects/midi-lib/tsconfig.lib.json file ?

Here is how I package and then use the library:

To package the library, add the following scripts in the scripts array of the parent package.json file

"copy-license": "cp ./LICENSE.md ./dist/midi-lib",
"copy-readme": "cp ./README.md ./dist/midi-lib",
"copy-files": "npm run copy-license && npm run copy-readme",
"build-lib": "ng build midi-lib",
"npm-pack": "cd dist/midi-lib && npm pack",
"package": "npm run build-lib && npm run copy-files && npm run npm-pack",

and run the command: npm run package

then install the dependency

npm install ../midi-lib/dist/midi-lib/midi-lib-0.0.1.tgz

and import the module in the application module In the app.module.ts file have:

import { MidiLibModule } from 'midi-lib';
@NgModule({
  imports: [
    MidiLibModule

finally insert the component in a template

<midi-midi-lib></midi-midi-lib>

When the library is installed in a client application, it has lots of .d.ts files in the node_modules/midi-lib directories:

├── bundles
├── esm2015
│   └── lib
│       ├── device
│       ├── keyboard
│       ├── model
│       │   ├── measure
│       │   └── note
│       │       ├── duration
│       │       └── pitch
│       ├── service
│       ├── sheet
│       ├── soundtrack
│       ├── store
│       ├── synth
│       └── upload
├── esm5
│   └── lib
│       ├── device
│       ├── keyboard
│       ├── model
│       │   ├── measure
│       │   └── note
│       │       ├── duration
│       │       └── pitch
│       ├── service
│       ├── sheet
│       ├── soundtrack
│       ├── store
│       ├── synth
│       └── upload
├── fesm2015
├── fesm5
└── lib
    ├── device
    ├── keyboard
    ├── model
    │   ├── measure
    │   └── note
    │       ├── duration
    │       └── pitch
    ├── service
    ├── sheet
    ├── soundtrack
    ├── store
    ├── synth
    └── upload

Like this one lib/service/melody.service.d.ts file

import { SoundtrackStore } from '@lib/store/soundtrack-store';
import { ParseService } from '@lib/service/parse.service';
import { CommonService } from './common.service';
export declare class MelodyService {
    private soundtrackStore;
    private parseService;
    private commonService;
    constructor(soundtrackStore: SoundtrackStore, parseService: ParseService, commonService: CommonService);
    addSomeMelodies(): void;
    private addSoundtrack;
    private generateNotes;
}

As can be seen, it contains references to the @lib path mapping, which is not known in the client application.

I also tried to use the baseUrl property as a work around, but that didn't help either, as when installing the library, this baseUrl value was not specified.

Why is packaging the library with the command npm run package not resolving the paths mappings ?

Ostrander answered 9/7, 2019 at 9:30 Comment(3)
Sometimes ago I faced exactly the same issue when I was working on adding PWA to my angular app, it's strange that baseUrl didn't help, for me, it helped. So my solution is: "baseUrl": "./", "paths": { "@app/*": ["./src/app/*"] } However, if I remove "./", and put "@app": ["src/app/*"] instead of ["./src/app/*"] it fails, that was tricky "./" until I've found it.Lewendal
have you tried using relative paths inside of your midi-lib? i think this would solve the issue on the client applicationTeletypesetter
@Teletypesetter I was using relative paths at first, but having '../../../../..' paths prefixes all over the code base is no fun, so I went to use absolute paths.Ostrander
N
23

The paths mapping you establish in your tsconfig.json is purely a compile-time mapping. It has no effect on the code generated by the TypeScript compiler. Which is why you have a failure at run time. That's something that has been reported to the TypeScript project, suggesting that tsc should automatically translate module paths in emitted code to conform to the mapping established by paths. The TS devs responded tsc is working as intended and that the solution is to configure a module loader that performs at run time a mapping similar to that established by paths.


Here what I think you should do, based on how you described your case.

I'm assuming that midi-app is a test application that is not meant to be distributed. You should be able to continue using the paths mapping you have without any issue. (You've not mentioned any issue running this app. So it seems your tooling already takes care of the runtime issue.)

For midi-lib, I would stop relying on the mappings established by paths and just use relative paths. This is a library, meant to be consumed by others. Because of this, any configuration that would fix the module name mapping at run time (or at bundling time) would have to be handled by the consumers of your library. Consumers that use Webpack will have to add a configuration to their Webpack configuration to provide the right mapping. Consumers that use Rollup would have to do the same with Rollup. Consumers that use SystemJS would have to do the same with SystemJS, etc.

Moreover, the required configuration could get complicated depending on the context in which your library is used. As long as your library is the only one needing to map @lib to some path, the mapping that must be added to Webpack (or SystemJS, etc.) can be global. The module bundler or module loader will always replace @lib with your path, which is fine because your package is the only one that needs @lib replaced. However, suppose another library author does exactly what you did, and a consumer of your library also uses that other library. Now you have a situation where @lib must be mapped to one path in some cases, and must be mapped to another path in other cases. This can be configured, but it requires more complex configuration.

I've focused on the issue of resolving modules during bundling or when loading them at runtime, but there's another issue. Consumers would also need to configure their tsc compilation with a special configuration because the .d.ts files

If you just use relative paths in your code then consumers of your library won't have to worry about adding special configurations to accommodate your library's special needs.


There's a special case that may happen to fit your case. If your library is going to be published as midi-lib then you can change your paths map so that instead of @lib/* you have a map for midi-lib/*:

"midi-lib/*": ["projects/midi-lib/src/*"],

(Note that the @ symbol has no special meaning as far as TypeScript is concerned. Also note if your package is meant to be installed with a scope, like @midi-project/midi-lib then you need the scope in the tsconfig.json mapping too: "@midi-project/midi-lib/*": ...)

Basically, the goal here is to set a mapping that allows you to import modules in your project in exactly the same way a consumer of your project would import individual modules from it. If a consumer of your module would import the ParseService with import { ParseService } from "midi-lib/lib/service/parse.service", then in your own code you'd use the same import when you want to use that module. (Note that it does not matter whether you tell consumers to import this module directly. If consumers were to import the module directly, then what path would they use?) So the same path works at compile time and at run time (or bundling time). At compile time, tsc converts the path. At run time or bundling time, Node's module resolution algorithm (or a tool which can follow the same algorithm, like Webpack or Rollup) converts the path.

How much typing you'd save with this highly depends on the names you've chosen and how you structured your library.


In theory, you could have a step after you run ng build that would go over the files produced by ng build and replace @lib in module names with the actual path it is supposed to point to. The problems with this:

  1. It's not just a matter of running a single tool or flipping a flag in a configuration option. Maybe a tool like rollup can transform the JS files but you need to now learn how it works and write a configuration for it.

  2. AFAIK there's no readily available tool that will transform the .d.ts files as you need them. You'd most likely have to write your own tool.

  3. You'd also need to patch the AOT compilation metadata generated by the Angular AOT compiler because it also contains module references, and these references are used by consumers of your library. AFAIK, there's no such tool that exists. So here too you'd have to roll your own.

  4. Your build process could break if a new Angular version changes the format of the AOT compilation metadata or adds a different type of metadata file that needs patching. I know this from experience: I have a couple of packages that are highly experimental Angular applications. For historical reasons, they entirely bypass using Angular CLI for building. Every Angular upgrade from version 4 onwards broke something in the build process of these applications. It often had to do with how AOT compilation metadata was handled.

Nubianubian answered 31/7, 2019 at 12:37 Comment(8)
I'm under Angular 8. How can I offer my library, not as modules, but as a single file then ? Should I use Webpack ? Does Angular have a way to do that ?Ostrander
If you are using Angular's stock build system you are already using Webpack through that build system. I don't know of a way to tell Angular's build system to just build a bundle and not the rest. Even if you could, I suspect it would impact what consumers can do with your library. For one thing, they would not be able to optimally use tree shaking.Nubianubian
Should I understand we need to offer the library as modules ? Why making a Webpack bundle then ? I cannot believe we are forced to use these ugly ../../../../ import statements just because we are coding a library. There must be a way, to code a library in Angular using absolute import statements, and have this resolved before being bundled, so as to be consumed by any Node client application.Ostrander
Yes, the library should be packaged as a collection of modules. The bundle is really the optional part. The bundle is useful for some use-case scenarios, that's why it is made. However, a bundle alone is bad for cases where the consumers of a library wish to perform tree shaking. If you produce a library for which tree-shaking makes sense and you don't package it in a way that allows consumers to tree-shake in an optimal way, you get issue reports asking to produce a tree-shaking-friendly package.Nubianubian
@Ostrander I've added another possibility to my answer. There's a way to use paths in a way that makes the same module path resolve properly at compile time and at run time. It is not clear that your use-case-scenario allows for it... but I've added it, in case it does.Nubianubian
Could I use a schematics for that ? I could put the schematics in place angular.io/guide/schematics-for-libraries But I have not yet a specific rule. Could I code a rule that would resolve the paths ?Ostrander
@Ostrander I suppose you could use schematics for that. It seems to me though that the issues I pointed out in the last section of my answer (adding a build step after ng build to patch the module paths) would remain: you still have to deal with them, only now you'd have to deal with them through schematics. There's a definite downside though to using schematics: the transformations would be performed each time a consumer installs your library, rather than just once, at build time.Nubianubian
I was hopeful that the "same alias" approach would work for my angular modules. However when I try it I get errors during client app compile. This is because in my library code I have imports like {DemoService} from "@scope/test-api/services/demo.service.ts" but all my client imports are things like {DemoService} from "@scope/test-api"} - I believe this is because in ng-packagr packaged libraries we expose everything through public_api. I think this will mean it isn't possible to have a "private" import within library code (i.e. one that isn't also exposed to the client)Melidamelilot
S
1

As others have said, typescript does not modify your @app and @lib imports. I've run into the same issue trying to use absolute paths in a library package. What you need is to prepare your library for publishing with rollup or something similar.

Rollup has various plugins, and I won't cover the full setup, but what you need is a plugin that will rewrite your imports. This plugin looks like it does it: https://github.com/bitshiftza/rollup-plugin-ts-paths

For the rest of the rollup configuration, you'll likely need rollup-plugin-node-resolve and rollup-plugin-commonjs, as well as a plugin to handle typescript (rollup-plugin-typescript), or you could go the newer route of using babel. Search for some guides, because there are popular libraries written in typescript that use rollup to prepare their code for packaging (React, for one).

Happy coding.

Sal answered 1/8, 2019 at 18:42 Comment(7)
For better or worse, Angular has settled on Webpack for its toolchain. Someone switching to rollup would be charting a course into a territory which is not directly supported by the Angular project. Speaking as someone who has charted such course (though not with rollup), I can say from experience that this is a very demanding thing to do.Nubianubian
@Nubianubian an Angular app can use webpack to build the bundle, but to author libraries you can use any means to create the actual package. Once created, the package should be usable by any Angular app. Or are you saying the OP is using the Angular CLI to create the library package too?Sal
Hmm.... I'm not sure what you're aiming at. The OP has a script in package.json that goes "build-lib": "ng build midi-lib", so the OP is using Angular CLI to build the library. This is what produces the whole tree of files that the OP shows, and this is what is the contents of the package. Maybe you mean to suggest that there should be a step between ng build and the execution of npm pack that would run rollup on the output of ng build?? Ok, how is the AOT compilation metadata going to be handled by rollup? It also contains module names that would need to be patched.Nubianubian
@louis I guess I missed that part of his post. I thought by "library" he meant a separate project that builds an npm package. I don't know anything about the Angular CLI, sorry.Sal
I thought I could use Angular CLI to build and package and ship my library. I'm surprised the Angular toolbox does not offer a simple feature as absolute imports for a library.Ostrander
I can see the Angular components project angular/components.git does make use of paths, and this project is a library. So if they can do it, we, or I :-) should be able to do it as well.Ostrander
I resorted to use absolute paths only for application projects, that is, non library projects, and to use relative paths for library projects. I reverted to relative paths for my library and I can now use it fine in my application.Ostrander

© 2022 - 2024 — McMap. All rights reserved.