Introducing TypeScript to the Umbraco backoffice

Last updated: 11 August 2021

It’s been on the Umbraco radar for a while (read: years), and looks like becoming a reality when the new backoffice UI arrives, but in the meantime, let’s explore how we can integrate TypeScript into the backoffice, today.

But first, what the hell is TypeScript?

It’s JavaScript with superpowers. It builds on JS by adding static type definitions, which means more easily understood objects, better documentation and compile-time error checking (which is waaay better than finding bugs in the browser).

As a quick example - if your user object has a property userId, of type number, the TS compiler will prevent you from accidentally assigning a string value, or trying to access id instead of userId.

For those coming from a C# background, which I’d expect would be a lot of, if not most Umbraco developers, this should all sound familiar.

TS is opt in, in the sense that your existing JS project can become TS as rapidly or as slowly as suits. You don’t even need to change file types, the TypeScript compiler is perfectly ok with compiling .js files.

Ultimately, you’re still writing JS, it’s just better JavaScript.

As for TS in Umbraco, it’s a bit of fiddling, but well worth the effort when working on a larger project or package.

There are myriad ways to set up a new project, however in my case the goal was to convert the Plumber codebase from JS to TS, and increase my confidence that the code is doing what I expect.

Admittedly too my approach to writing JavaScript is likely a way removed from a lot of package developers. I wanted to write a particular flavor, so have invested a chunk of time into developing a build pipeline to let me do so, which has the added benefit of playing nicely with TypeScript.

I use Gulp and Browserify to manage my build, which allows me to write modern, modular JavaScript, and ship compiled, browser-friendly files. Rather than sharing the entire Gulp setup, here’s the important bits for compiling TS down to JS:

/* other imports omitted for brevity */
/* paths is an object, containing a collection of paths used by Gulp, config is exactly that - a collection of configuration information */
import { paths, config } from './config'; 

/* other similar functions are exported, for generating different JS bundles */
export function js() {
    return _js(paths.js, 'plumber.js');
}

function _js(glob, filename) {
    /* gulp expects tasks to return a stream, so we create one here. */
    var bundledStream = through();
    bundledStream
        /* turns the output bundle stream into a stream containing the normal attributes gulp plugins expect. */
        .pipe(source(filename))
        /* the rest of the gulp task, as you would normally write it. */
    );

    /* "globby" replaces the normal "gulp.src" as Browserify creates it's own readable stream. */
    globby(glob).then(entries => {
        browserify({entries, debug: !config.prod, extensions: ['.ts', '.js']})
            .plugin(tsify) /* TS becomes JS */
            .transform(babelify.configure({
                presets: ['@babel/preset-env'] /* Babel does magic to make modules browser-friendly */
            }))
            .bundle()
            .pipe(bundledStream);
    }).catch(err => bundledStream.emit('error', err));

    /* finally, we return the stream, so gulp knows when this task is done. */
    return bundledStream;
}

By running gulp watch(paths.js, gulp.series(js, // plus more tasks)) I can have my TS compiled to JS and copied to an output directory whenever JS files change. Lovelyjubbly.

To make my JS project TS friendly, I need a tsconfig.json file, in the same directory as my package.json. There is a stack of configuration options, which are best explained here

You’ll also need some NPM dependencies, typescript being the main one (obvs), others will depend on your particular build pipeline.

So, jumping a bit of the wire-up, let’s assume we have Gulp set up to watch our TS files and compile them to plain old JavaScript. How then do we leverage TS in Umbraco?

Heaps of projects have existing types publically available (checkout DefinitelyTyped), but unfortunately Umbraco is not among them (yet).

This means we’re going to be creating our own.

A type definition (denoted by the .d.ts extension) tells the compiler what is a valid shape for a given object. TS is pretty clever in that it will infer types where possible, particularly for variables or simple objects, however this doesn’t help when we’re building a package, as the Umbraco JS exists outside the scope of our project.

While TS will infer from const name = 'Foo' that name must be a string, it can’t know that the object returned from Umbraco’s languageResource has a culture property also of type string. By adding type definitions, we can help the compiler to help us.

For a concrete example, let’s look at how I’ve implemented pagination for Plumber’s dashboards.

I’m using the existing Umbraco umbPagination component, because reuse is cool and reinventing wheels is not.

The pagination component is used in a view like so:

    <umb-pagination ng-if="vm.pagination.totalPages > 1 && vm.items.length"
        page-number="vm.pagination.pageNumber"
        total-pages="vm.pagination.totalPages"
        on-next="vm.pagination.goToPage"
        on-prev="vm.pagination.goToPage"
        on-go-to-page="vm.pagination.goToPage">
    </umb-pagination>

It needs some attributes to provide information for the component, all of which are coming from the vm.pagination object. We can assume that pageNumber is a number, and goToPage might be a function, but we don’t know for sure. We don’t know if goToPage accepts parameters. We don’t know until we provide an invalid value, and the code breaks in the browser.

Enter TypeScript.

We create an interface to define what the shape of the pagination object. This definition exists in a .d.ts file, and looks like this:

interface IPagination {
    pageNumber: number;
    totalPages: number;
    perPage: number;
    goToPage: (page: number) => void;
}

Next, the interface is used when creating the concrete type (again, should be pretty familiar for C# developers):

export class Pagination implements IPagination {
    pageNumber: number = 1;
    totalPages: number = 0;
    perPage: number;

    goToPage;

    constructor(cb: Function, perPage: number = 10) {
        this.perPage = perPage;

        this.goToPage = i => {
            this.pageNumber = i;
            cb();
        }
    }
}

Within the Pagination class I’m implementing the interface and adding some logic in a constructor. Without pageNumber and co, the compiler will show errors about missing properties, which is one of the key reasons we use TypeScript - it ensures we fulfill the contracts we’ve promised, so that when we come to using our pagination class, we know it will have a pageNumber property, defaulting to 1.

The constructor logic lets me shortcut the wireup for the paginator in my components. Rather than having to initialise the object every time, and set defaults, and add logic for the goToPage function, I can do this:

export class MyComponent {
    pagination: Pagination = new Pagination(() => this.fetch());
    myService: IMyService;
    
    constructor(myService: IMyService) {
        this.myService = myService;
    }

    fetch() => {
        this.myService.getSomething(this.pagination.pageNumber, this.pagination.perPage)
            .then(response => // do stuff);
    }
}

In the example, I have a basic class with a pagination property, of type Pagination, which is passed a callback, which is assigned to the goToPage function in the Pagination object.

When the user selects a page, or clicks the next/previous buttons, the callback executes and the fetch method on the component is called, with the parameters from the paginator, both of which are managed interally in that object, and we can trust that they’ll be available, with valid values, becuase if they weren’t we’d get red squiggles in our IDE.

Similar logic can be extended to Umbraco’s services and other objects, so that our calling code knows what to expect in the response.

interface ILocalizationService {
    localizeMany: (keyArray: Array<string>) => Promise<Array<string>>;
    localize: (key: string, tokens?: Array<string>) => Promise<string>;
}

export class MyLocalizedComponent {
    localizationService: ILocalizationService;
    
    constructor(localizationService: ILocalizationService) {
        this.localizationService = localizationService;
    }

By adding the ILocalizationService interface, and adding the types to the referencing component, it’s now impossible to call a non-existent method, or to provide invalid arguments.

If I tried to call this.localizationService.localise('foo'), I’d be warned the method doesn’t exist and compilation will fail.

If I tried to call this.localizationService.localize(['foo', 'bar']), I’d be warned that the parameter is invald, and compilation will fail.

I said at the outset the TypeScript is opt in, which means that I can add interfaces and definitions as required, I don’t need types for the entire Umbraco codebase, just the bits I’m interacting with.

It’s a bit of additional effort given TS isn’t part of the Umbraco ecosystem, but in the future, when the backoffice is rebuilt and the type definitions exist, we’ll be able to write much more robust code, trusting the TS compiler to catch our mistakes before we push anything anywhere.

TypeScript won’t catch everything - it won’t recognise logical errors - but it makes life considerably more pleasant, entirely justifying the time sunk in to setup and config.