Trying TypeScript for GNOME Apps

JavaScript is an excellent language for getting things done quickly. However, flexibility and speed come at the cost of not catching certain classes of errors before your code runs. For example, you can easily miss type errors and syntax errors. Developers can address these issues via type annotations and static analyzers like ESLint, but that requires a significant amount of effort on the developer’s part – which contrasts with the ability to write a program in a flexible language quickly.

Generally, I prefer to work in a language that watches my back – the tooling is my partner as I write my code. Rust is an exemplary language in this department, but certain design decisions make it slower to write an app in Rust than in JS. I figured it wasn’t the right choice for the JS apps I maintain, but I also wasn’t fond of continuing to work in plain JS. These reasons led me to look into TypeScript, and after some research, I decided I would port an app as an experiment: GNOME Sound Recorder. As far as I know, this is the first complete application in the GNOME ecosystem written in TypeScript.

I ported the app in 6 main steps:

  • Porting to ES modules
  • Using type-checked JS
  • Porting to TypeScript in full
  • Enabling strict type checking
  • Re-adding ESLint
  • Fixing the flatpak manifest and our CI

With some minor cleanups and style changes sprinkled in. Before we dive in, I want to say thank you to everyone who has helped with this effort, particularly:

  • Zander Brown
  • Philip Chimento
  • Sonny Piers
  • Andy Holmes
  • Evan Welsh
  • Michael Murphy

Everyone here provided invaluable advice, tooling, or code that helped me in the process of working on this.

Porting to ES Modules

While Sound Recorder may be the first TypeScript app, I am not the first person in GNOME to work with TypeScript. The developers at System76 are using it to write their tiling shell extension. Evan Welsh created a type definition generator and published type definitions on npm. 2 years ago, Michael Murphy was posting issues on a few GJS repositories about porting to TypeScript, and I noticed one such issue. I was curious, so I wrote an email, and he sent some helpful information about how their shell extension worked.

When I decided to give TypeScript a try recently, I remembered that information. I noticed that System76 has a script to change the module syntax of their TypeScript to the style of imports and exports that GJS supported at the time. Thankfully GJS now supports modules – so there’s no script required. Or rather, we wouldn’t need a script if we transitioned the Sound Recorder codebase to use modules. So I did precisely that. Most GJS code already uses modules, so it was something I needed to do as a general housekeeping task anyway.

Type-checked JavaScript

Type-checking JavaScript is the use case that most developers have for using the GJS type definitions, but it’s also a first step in integrating TypeScript tooling with your JS code. The typescriptlang.org docs have a chapter on setting it up. At this stage, I received completion in my text editor and a few warnings for mistakes that no one had noticed. For example, I forgot to have a return statement in a non-void virtual function.

It wasn’t all sunny at first – GJS has some built-in functions and objects that it allows you to access globally. The TypeScript checker wasn’t aware of those at all. Philip Cemento helped me learn how to define pkg and the _() helper for translations in an ambient type definition file. I also needed to re-declare modules like gtk as gi://Gtk so that I could import them in the way that GJS would expect.

In addition to the module pains, there was another issue: The GJS-provided GObject.registerClass() function sets up property and template children fields. If you have a child named button, GJS will allow you to refer to it as _button. To get type-checking for fields, I needed to declare them at the beginning of the class. However, re-declaring the fields set them to null, breaking the application. So I came up with an ugly hack:

// @ts-ignore
/** @type {Gtk.Stack} */ _mainStack = this._mainStack;
// @ts-ignore
/** @type {Adw.StatusPage} */ _emptyPage = this._emptyPage;
// @ts-ignore
/** @type {Adw.Clamp} */ _column = this._column;
// @ts-ignore
/** @type {Gtk.Revealer} */ _headerRevealer = this._headerRevealer;
// @ts-ignore
/** @type {Adw.ToastOverlay} */ _toastOverlay = this._toastOverlay;

Yeah, that’s not pretty at all. I was scared to continue after writing such hacky code – I wasn’t sure if the final product would be this awful. Still, I pressed on.

Initial TypeScript Port

This part was surprisingly uneventful. I mainly edited the code to provide type annotations for functions and closures. I already annotated fields in the previous step – I just needed to switch to TypeScript syntax. The new syntax came with a big boon: I could clean up the messy hack I had before. TypeScript has a ! operator that can be used on fields, telling the linter that you know it may seem like a field isn’t there, but it really is there. So the above code became:

_mainStack!: Gtk.Stack;
_emptyPage!: Adw.StatusPage;
_column!: Adw.Clamp;
_headerRevealer!: Gtk.Revealer;
_toastOverlay!: Adw.ToastOverlay;

After this, I was pretty happy with the app’s state – until my friend Zander Brown told me I could make TypeScript even stricter.

Strict Type Checking

Strict type checking enables null safety in TypeScript. That means that for every value that can be undefined or null, you must declare and handle those states explicitly. Typescript has some neat shorthand for this. For example:

level?: Gst.Element;

Here the ? means that level can either be a Gst.Element or undefined. You only need this for fields you don’t set when declared or in the constructor.

let audioCaps = Gst.Caps.from_string(profile.audioCaps);
audioCaps?.set_value('channels', this._getChannel());

? can also be used as a quick null check. Gst.Caps.from_string() returns Gst.Caps | null. So in the following line, audioCaps?.set_value() is saying “if audioCaps exists, set a value.”

This stage helped me catch areas where we simply forgot to check whether or not a value could be null before using it.

ESLint

ESLint caught a bunch of formatting and convention issues, but it also let me know that a “clever trick” I thought of to get around some troubling code blocks wasn’t so clever.

In JS I had this segment:

_init() {
    this._peaks = [];
    super._init({});

    let srcElement, audioConvert, caps;
    try {
        this.pipeline = new Gst.Pipeline({ name: 'pipe' });
        srcElement = Gst.ElementFactory.make('pulsesrc', 'srcElement');
        audioConvert = Gst.ElementFactory.make('audioconvert', 'audioConvert');
        caps = Gst.Caps.from_string('audio/x-raw');
        this.level = Gst.ElementFactory.make('level', 'level');
        this.ebin = Gst.ElementFactory.make('encodebin', 'ebin');
        this.filesink = Gst.ElementFactory.make('filesink', 'filesink');
    } catch (error) {
        log(`Not all elements could be created.\n${error}`);
    }

    try {
        this.pipeline.add(srcElement);
        this.pipeline.add(audioConvert);
        this.pipeline.add(this.level);
        this.pipeline.add(this.ebin);
        this.pipeline.add(this.filesink);
    } catch (error) {
        log(`Not all elements could be addded.\n${error}`);
    }

    srcElement.link(audioConvert);
    audioConvert.link_filtered(this.level, caps);
}

Many of these functions could return null, so I had no idea how to rework this segment nicely. I cheated and used the ! operator to tell TS to ignore the nullability of these items:

constructor() {
    super();
    this._peaks = [];

    let srcElement: Gst.Element;
    let audioConvert: Gst.Element;
    let caps: Gst.Caps;

    this.pipeline = new Gst.Pipeline({ name: 'pipe' });

    try {
        srcElement = Gst.ElementFactory.make('pulsesrc', 'srcElement')!;
        audioConvert = Gst.ElementFactory.make('audioconvert', 'audioConvert')!;
        caps = Gst.Caps.from_string('audio/x-raw')!;
        this.level = Gst.ElementFactory.make('level', 'level')!;
        this.ebin = Gst.ElementFactory.make('encodebin', 'ebin')!;
        this.filesink = Gst.ElementFactory.make('filesink', 'filesink')!;
    } catch (error) {
        log(`Not all elements could be created.\n${error}`);
    }

    try {
        this.pipeline.add(srcElement!);
        this.pipeline.add(audioConvert!);
        this.pipeline.add(this.level!);
        this.pipeline.add(this.ebin!);
        this.pipeline.add(this.filesink!);
    } catch (error) {
        log(`Not all elements could be addded.\n${error}`);
    }

    srcElement!.link(audioConvert!);
    audioConvert!.link_filtered(this.level!, caps!);
}

Using that operator was awful, and ESLint refused to let me get away with it. Thankfully Philip Chimento helpfully provided a solution with tuples and destructuring:

constructor() {
    super();
    this._peaks = [];

    let srcElement: Gst.Element;
    let audioConvert: Gst.Element;
    const caps = Gst.Caps.from_string('audio/x-raw');

    this.pipeline = new Gst.Pipeline({ name: 'pipe' });

    const elements = [
        ['pulsesrc', 'srcElement'],
        ['audioconvert', 'audioConvert'],
        ['level', 'level'],
        ['encodebin', 'ebin'],
        ['filesink', 'filesink']
    ].map(([fac, name]) => {
        const element = Gst.ElementFactory.make(fac, name);
        if (!element)
            throw new Error('Not all elements could be created.');
        this.pipeline.add(element);
        return element;
    });

    [srcElement, audioConvert, this.level, this.ebin, this.filesink]  = elements;

    srcElement.link(audioConvert);
    audioConvert.link_filtered(this.level, caps);
}

Fixing Flatpak & CI

Since TypeScript is part of the general JS ecosystem that uses npm.js, I somehow needed to integrate the npm sources with my own. Thankfully, a handy manifest generator provides a list of sources in a format flatpak knows how to handle. I couldn’t find a good way for my build scripts to access the directory where flatpak installed the sources, so I needed to set up a module with steps to move the sources somewhere visible to my app module:

{
    "name" : "yarn-deps",
    "buildsystem" : "simple",
    "build-commands" : [
        "/usr/lib/sdk/node18/enable.sh",
        "mkdir -p /app",
        "cp -r $FLATPAK_BUILDER_BUILDDIR/flatpak-node/yarn-mirror/ /app"
    ],
    "sources" : [
        "generated-sources.json"
    ]
}

Then I set up meson to take the mirror dir as an option. After verifying that my build still worked locally and within flatpak, fixing my CI was as simple as adding two lines to the YAML:

  before_script:
    - flatpak --user install -y org.freedesktop.Sdk.Extension.node18//22.08beta

Next Steps

There are still two remaining tasks before I consider this port fully finished:

  • Make Sound Recorder use Promises and async/await syntax
  • Remove the type definitions from the repo and use them from npm

Promises have a significant papercut, making them difficult to use in the current state. There are workarounds, but none that I’ve gotten to work. For the type definitions, having less code to maintain in the Sound Recorder repo will be helpful.

Porting Sound Recorder to TypeScript has been a positive experience overall. I want to continue using TypeScript in place of plain JavaScript, including in core apps like Weather. There are a few questions that the community needs to answer before I would feel comfortable using JavaScript in core components:

  • How should distros (or component developers) handle sources from NPM?
  • Are we as a community comfortable depending on the NPM ecosystem?

I look forward to seeing how discussions about these questions pan out.

If you’re interested in following and supporting my future work, please consider sponsoring me on Patreon, GitHub Sponsors, or with one-time donations via PayPal:

In the next few days, I plan to share what I’ve done since my previous planning post.