Deploying TypeScript Type Definitions for Umbraco v14 Packages

5 min read

In a recent blog series I shared how to go about creating your own Umbraco v14 extensions, but the final piece of the puzzle that I left out was how to deploy your extension type definitions such that others implementing your extensions would have nice type checking.

Well, in this article we are going to look at doing exactly that.

The Challenge

The challenge for Umbraco Package developers that provide their own extension points is that we actually need to split out deployments into two.

  1. For the actual code that makes up your package, this is likely going to be packaged up in your NuGet package.
  2. For your extension points, we need to distribute the types for these as an NPM package.

I won’t bother covering the first part as Kevin Jump has already done a great job of outlining how to setup a project for an Umbraco package.

Here then I’ll focus on the second point and how to extend your project to distribute your types.

To set a baseline however, I’ll assume you’ve used Kevin’s early adopters template for your setup.

Step 1: Consolidate Your Types

We don’t want to export all types in our package, rather we just we to export our extension types, such as our custom manifest types. To make this explicit, I decided to add a root level exports.js file that exports all the types I needed to make public.

export type { 
    ManifestQuickAction,
    MetaQuickAction
} from './quick-action/types.js'

For brevity here I’m just exporting the types directly in the root level exports.js file, but in actuality, I have a number of exports.js files located in my project and my root level exports.js file re-exports all the other export files like export * from './feature/exports.js

Step 2: Generate Your Type Definitions

Now we have all our types exported in one place, we next need to use the TypeScript CLI tool to generate our type definitions.

In your package.json add a new script entry as follows

{
  "script": {
    "build:api": "tsc -p tsconfig.api.json",
  }
}

Next create a tsconfig.api.json with the following contents

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./types",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly" : true
  }
}

Essentially this is telling TypeScript to use our main tsconfig.json configuration, but to only generate type definitions.

Now if you run this script

npm run build:api

We should get a new types folder with all our generate .d.ts files.

TypeScript Output

One glaring issue here though is that we’ve generated type definitions for our whole package and not just the ones we want to export. At best this is a whole lot of bloat, but at worse, it’s now exposing all of our packages types, not just the ones we want to make public.

Unfortunately this is just how the TypeScript CLI works, but we can do some further processing of these files to get where we want to get to.

Step 3: Rollup Your Type Definitions

What we ideally want then is a single .d.ts file that contains all of our exported types and nothing else.

Thankfully we can achieve this using third party tool, API Extractor.

API Extractor is capable of a lot of cool features, but for the purposes of this article, we’ll focus on it’s rollup feature.

First then, install API Extractor as a dev dependency.

npm install @microsoft/api-extractor --save-dev

Next, we need to create a config for API Extractor which we can do by running the command

npx api-extractor init

This will generate a new api-extractor.json file in our root folder. By default this file contains a lot of comments to help document all the features, but for now, we can just replace it’s contents with the following

{
  "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
  "projectFolder": ".",
  "mainEntryPointFilePath": "<projectFolder>/types/exports.d.ts",
  "compiler": {
    "tsconfigFilePath": "<projectFolder>/tsconfig.api.json"
  },
  "apiReport": {
    "enabled": false
  },
  "docModel": {
    "enabled": false
  },
  "dtsRollup": {
    "enabled": true,
    "publicTrimmedFilePath": "<projectFolder>/types/my-package.d.ts"
  }
}

Essentially here we are telling API Extractor where to find our types entry point, which in our case is our consolidated exports.d.ts file along with the path to our tsconfig file.

We then disable the features we don’t need, but enable the dtsRollup feature and give it a publicTrimmedFilePath of where we want to export our rolled up types to.

With this in place, we can now update the script in our package.json as follows

{
  "script": {
    "build:api": "tsc -p tsconfig.api.json && api-extractor run --verbose",
  }
}

Now when you execute the script, you’ll find there is an extra my-package.d.ts file added to our types output folder

API Extractor Output

If we take a look inside this file, you should see something similar to the following

import { HTMLElementConstructor } from '@umbraco-cms/backoffice/extension-api';
import { LitElement } from '@umbraco-cms/backoffice/external/lit';
import { ManifestBase } from '@umbraco-cms/backoffice/extension-api';
import { ManifestElement } from '@umbraco-cms/backoffice/extension-api';
import { ManifestElementAndApi } from '@umbraco-cms/backoffice/extension-api';
import { UmbApi } from '@umbraco-cms/backoffice/extension-api';
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import { UmbElement } from '@umbraco-cms/backoffice/element-api';
import { UmbExtensionElementInitializer } from '@umbraco-cms/backoffice/extension-api';

export declare type DefaultManifestQuickActionKind = ManifestKind<ManifestQuickAction> & ManifestBase;

export declare interface QuickActionApi extends UmbApi {
    manifest: ManifestQuickAction;
    execute(): Promise<void>;
}

export declare interface QuickActionElement extends UmbControllerHostElement {
    manifest: ManifestQuickAction;
    api?: QuickActionApi;
}

export declare interface ManifestQuickAction extends ManifestElementAndApi<QuickActionElement, QuickActionApi> {
    type: 'quickAction';
    meta: MetaQuickAction;
}

export declare interface MetaQuickAction {
    entityType: string;
    label: string;
    look?: 'primary' | 'secondary';
}

Step 4: Package Your Type Definitions

Now that we have all our public type in a single file, we can now move on to packaging things up.

First, update your package.json adding the following types, files and peerDependencies values.

{
  "name": "my-package",
  "version": "14.0.0",
  "type": "module",
  "types": "./types/my-package.d.ts",
  "files": [
    "types/my-package.d.ts",
  ],
  "peerDependencies": {
     "@umbraco-cms/backoffice": "^14.0.0",
  }
}

Next, add an additional script as follows

{
  "script": {
    "pack:api": "npm pack",
  }
}

And finally, execute this script in your terminal

npm run pack:api

Now in the root of your project, you should see a my-package-14.0.0.tgz file, which if we look into should contain the following files/structure.

package
├─ types
│  └─ my-package.d.ts
└─ package.json

And with that, we now have an NPM package we can publish using npm publish.

Step 5: Define an Import Map

There is one final step we need to implement, and that is to define an import map for our package.

This is required in order that when anyone used your NPM package and imports a type like import { ManifestQuickAction } from 'my-package', when this runs in the browser, we need to tell it where my-package actually resolves to.

To setup an import map, update your umbraco-package.json and add the following

{
  ...
  "importmap": {
    "imports": {
      "my-plugin": "/App_Plugins/MyPlugin/my-plugin.js"
    }
  }
}

And that should be all we need to implement.

Gotchas

There is one major gotcha I hit with using API Extractor and that is that it doesn’t support using custom TypeScript path shortcuts as I recently blogged about. Unfortunately API Extractor assumes all TypeScript path declarations refer to external libraries and so failures ensue.

Ultimately this resulted in me needing to strip out those shortcuts and update all paths back to being relative. That was a fun couple of hours.

Summary

I’ve tried to boil down the essence here of the steps that are required in order to publish your public API as NPM modules, but there is a lot more you can do with API Extractor as well as other useful scripts you might want to implement (I have some that dynamically updates the package version based on the build server output amongst other things).

But I hope this gives at least the important steps that you might want to implement to do just the same.