Back to list
Typed Javascript library
Published
Long

Typed javascript library

Making a reusable correctly typed library has always been something I wanted to achieve. I've tried multiple times but could never find the magic recipe.

Until I had to do it again and found this article by Raul Melo that describes his solution to successfully produce a Javascript library that works with types.

I ended up with the solution described below which mixes what you can find in his article and some custom things I had to do because my goals where not exactly the same as his.

The tools used to build this library:

To make it work and accept the definition I started with the above mentioned article. Like in the article, I want to provide multiple components as separated deliverables but I wanted also one that contained all of them. Meaning that the users can get the library the following ways:

The first version might reduce the final bundle size if it is the only library used in the project.

I know this might not be that important to some developers out there but hey, let's make a better and lighter web together.

Project structure

This is the structure of the project:

lib-x.ts are the different isolated libraries I want to publish.

src
  components
    Toasts          # Svelte Toast related component
    Toasts.svelte
    Toast.svelte
    toasts.ts
  lib-toast.ts      # file exposing the toast.xxx() methods
  lib-b.ts
  lib-c.ts
  lib-index.ts      # agregation of all libraries

Building the library

As my project aims to expose a function that renders a UI elements built using Svelte, I wanted the final bundle to contain the styling of the components because I don't see the use-case of having the components without its style. Because of that and the use of the vite-plugin-css-injected-by-js I cannot use the multi-library build of Vite as it fails to handle it correctly.

So I went back to the bundle.mjs solution described by the original post (thanks to him):

import { svelte } from '@sveltejs/vite-plugin-svelte';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { build } from 'vite';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';

const __dirname = dirname(fileURLToPath(import.meta.url));

const libraries = [
{
entry: resolve(__dirname, 'src/lib-toast.ts'),
fileName: 'lib-toast'
},
{
entry: resolve(__dirname, 'src/lib-a.ts'),
fileName: 'lib-a'
},
{
entry: resolve(__dirname, 'src/lib-index.ts'),
fileName: 'lib-index'
}
];

libraries.forEach((lib) => {
build({
plugins: [svelte(), cssInjectedByJsPlugin()],
build: {
lib: {
...lib,
formats: ['cjs', 'es']
},
emptyOutDir: false
}
});
});

This script builds each library one by one and outputs them in the dist folder.

Typings

This is where it gets a little sideways.

[Vite] allows you to configure where the resulting bundles but that seems to not be possible with tsc so the projects structure is crucial for the typings to get correctly interpreted by the IDE.

After generating the types (tsc src/*.ts --declarationDir dist/ --emitDeclarationOnly --declaration) the resulting package content:

dist
  lib-toast.js
  lib-toast.cjs
  lib-toast.d.ts
  lib-a.js
  lib-a.cjs
  lib-a.d.ts
  lib-b.js
  lib-b.cjs
  lib-b.d.ts
  lib-c.js
  lib-c.cjs
  lib-c.d.ts
  lib-index.js
  lib-index.cjs
  lib-index.d.ts

Having the definition aside the library with obviously the same name allows the IDE to stop complaining about the missing typings.

These are the relevant package.json parts:

{
"name": "my-library",
"files": [ "dist" ],
"types": "./dist/lib-index.d.ts",
"type": "modules",
"main": "./dist/lib-index.cjs",
"module": "./dist/lib-index.js",
"exports": {
".": {
"import": "./dist/lib-index.js",
"require": "./dist/lib-index.cjs"
},
"./toast": {
"import": "./dist/lib-toast.js",
"require": "./dist/lib-toast.cjs"
},
"./aaa": {
"import": "./dist/lib-a.js",
"require": "./dist/lib-a.cjs"
}
},
}

Frankly, I'm not sure about everything here. I got to this working situation and did not really wanted to dig deeper on this.

The exports section allows to expose the library the subpath way (import aaa from "my-library/aaa).

Extra: make agnostic component using [Svelte]

For the curious, this is the content of the src/lib-toast.ts:

import type { SvelteComponent } from 'svelte';
import { toast, type ToastCall, type ToastType } from './components/Toasts/toasts.js';
import Toasts from './components/Toasts/Toasts.svelte';

let instance: SvelteComponent;

function addContainerToDOM() {
if (!instance) {
instance = new Toasts({
target: document.body
});
}
}

const proxy = new Proxy(toast, {
get(target, prop, receiver) {
addContainerToDOM();
console.log('add', prop);
const value = target[prop as ToastType] as ToastCall;
if (value instanceof Function) {
return function (...args) {
return value.apply(this === receiver ? target : this, args);
};
}
return value;
}
}) as Record<ToastType, ToastCall>;
export default proxy;

As the library only exposes methods to add new Toasts, e.g. toast.success('Saved') or toast.error('Failed to save'), it needs to add the container for the toasts (the Toasts svelte component) if it is not already present. The proxy does exactly that.

Not show in this post, the original component are made available in the src directory. They can be used directly in another [Svelte] project.

Disclaimer/conclusion

This works but I'm not 100% certain if all the planets described in this post are to be aligned for it to work properly. From experience, having the x.d.ts file beside the x.js seems to be the most important thing to make for your types to appear in your IDE.

I miss still one feature which is the auto import: you start typing the function and it adds the import statement for you.