Skip to content

TypeScript and LSP

This page will guide you through creating an extension using TypeScript, which will allow autocompletion to work in your editor. The setup presented here is editor-agnostic and will work on any editor that support the Language Server Protocol (LSP) or has some internal equivalent functionality.

Creating the extension

Differently from what was previously done in the Getting Started section, the extension will now be created in a directory outside the installation folder. This is mostly because GNOME Shell does not support TypeScript, so we need a build phase to generate JavaScript from our files, and then the generated files will be copied into the installation folder.

To start, create a folder anywhere on your disk and, inside it, create the following files:

metadata.json

json
{
    "name": "My TypeScript Extension",
    "description": "An extension made with TypeScript",
    "uuid": "my-extension@example.com",
    "url": "https://github.com/example/my-extension",
    "settings-schema": "org.gnome.shell.extensions.my-extension",
    "shell-version": [
        "45"
    ]
}

This file does not contain anything different when compared to the one created in the Getting Started, but is necessary for the extension to work. Note that the specification has a version entry which we do not specify, since it is generated automatically by E.G.O (GNOME's online extension management system).

schemas/org.gnome.shell.extensions.my-extension.gschema.xml

Attention: this file goes inside a schemas folder.

xml
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
  <schema id="org.gnome.shell.extensions.my-extension" path="/org/gnome/shell/extensions/my-extension/">
    <key name="padding-inner" type="i">
      <default>8</default>
      <summary>Inner padding</summary>
      <description>Padding between windows</description>
    </key>
    <key name="animate" type="b">
      <default>true</default>
      <summary>Animation</summary>
      <description>Whether to animate window movement/resizing</description>
    </key>
  </schema>
</schemalist>

This is a schema, as described in the Preferences section. It is not necessary for an extension to work, but will be used in the example to show how automate steps and how to generate the final zip for distribution.

TypeScript setup

To use TypeScript we need some setup, installing some dependencies and configuring the project to generate files correctly. In this example both the extensions.js and prefs.js will be generated from their TypeScript counterparts. Create the following files:

package.json

json
{
  "name": "my-extension",
  "version": "0.0.0",
  "description": "A TypeScript GNOME Extension",
  "type": "module",
  "private": true,
  "repository": {
    "type": "git",
    "url": "git+https://github.com/example/my-extension.git"
  },
  "author": "Álan Crístoffer e Sousa <acristoffers@startmail.com>",
  "license": "LGPL-3.0-or-later",
  "bugs": {
    "url": "https://github.com/example/my-extension/issues"
  },
  "homepage": "https://github.com/example/my-extension#readme",
  "sideEffects": false
}

In this file, it is important to set "type": "module". You can set version to whatever you want, as it is not used.

Now that you have this file in place, you can run the following to install the dependencies:

sh
npm install --save-dev \
    eslint \
    eslint-plugin-jsdoc \
    typescript
npm install @girs/gjs @girs/gnome-shell

tsconfig.json

json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "sourceMap": false,
    "strict": true,
    "target": "ES2022",
    "lib": [
      "ES2022"
    ],
  },
  "include": [
    "ambient.d.ts",
  ],
  "files": [
    "extension.ts",
    "prefs.ts"
  ],
}

The TypeScript compiler configuration. Modifying it may break the build process.

ambient.d.ts

typescript
import "@girs/gjs";
import "@girs/gjs/dom";
import "@girs/gnome-shell/ambient";
import "@girs/gnome-shell/extensions/global";

This file makes it possible to use the usual import paths in your TypeScript files instead of referencing @girs/* directly.

The Extension and Preferences files

All the support files are in place, so we can finally write our extension's code. Create the following files:

extension.ts

typescript
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';

export default class MyExtension extends Extension {
  gsettings?: Gio.Settings
  animationsEnabled: boolean = true

  enable() {
    this.gsettings = this.getSettings();
    this.animationsEnabled = this.gsettings!.get_value('padding-inner').deepUnpack() ?? 8
  }

  disable() {
    this.gsettings = undefined;
  }
}

Thanks to @girs, the TypeScript code is basically what it would be if it were JavaScript + type information. If you have been following the tutorial, your LSP server should be able to offer information about the types in this file, like auto-complete and go-to-definition.

This is also the very minimum necessary to have a working extension: a default-exported class that extends Extension containing the methods enable() and disable(). You should not create constructors/destructors, and instead initialize your extension in enable() and finish it in disable(). This is because your class my be constructed once and reused internally, resulting in many calls to those methods.

prefs.ts

typescript
import Gtk from 'gi://Gtk';
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';

export default class GnomeRectanglePreferences extends ExtensionPreferences {
  _settings?: Gio.Settings

  fillPreferencesWindow(window: Adw.PreferencesWindow) {
    this._settings = this.getSettings();

    const page = new Adw.PreferencesPage({
      title: _('General'),
      iconName: 'dialog-information-symbolic',
    });

    const animationGroup = new Adw.PreferencesGroup({
      title: _('Animation'),
      description: _('Configure move/resize animation'),
    });
    page.add(animationGroup);

    const animationEnabled = new Adw.SwitchRow({
      title: _('Enabled'),
      subtitle: _('Wether to animate windows'),
    });
    animationGroup.add(animationEnabled);

    const paddingGroup = new Adw.PreferencesGroup({
      title: _('Paddings'),
      description: _('Configure the padding between windows'),
    });
    page.add(paddingGroup);

    const paddingInner = new Adw.SpinRow({
      title: _('Inner'),
      subtitle: _('Padding between windows'),
      adjustment: new Gtk.Adjustment({
        lower: 0,
        upper: 1000,
        stepIncrement: 1
      })
    });
    paddingGroup.add(paddingInner);

    window.add(page)

    this._settings!.bind('animate', animationEnabled, 'active', Gio.SettingsBindFlags.DEFAULT);
    this._settings!.bind('padding-inner', paddingInner, 'value', Gio.SettingsBindFlags.DEFAULT);
  }
}

This is also a good example of a minimal preference pane: a default-exported class that extends ExtensionPreferences and implements fillPreferencesWindow(window: Adw.PreferencesWindow). There are other methods that can be implemented instead, but this is the easiest to use. It also shows how to populate the window with some widgets which are bound to properties defined in the schema and therefore persisted.

Build and packaging automation

Since we now have a build step, it is better to automate it. Create the following file:

Makefile

NAME=my-extension
DOMAIN=example.com

.PHONY: all pack install clean

all: dist/extension.js

node_modules: package.json
	npm install

dist/extension.js dist/prefs.js: node_modules
	tsc

schemas/gschemas.compiled: schemas/org.gnome.shell.extensions.$(NAME).gschema.xml
	glib-compile-schemas schemas

$(NAME).zip: dist/extension.js dist/prefs.js schemas/gschemas.compiled
	@cp -r schemas dist/
	@cp metadata.json dist/
	@(cd dist && zip ../$(NAME).zip -9r .)

pack: $(NAME).zip

install: $(NAME).zip
	@touch ~/.local/share/gnome-shell/extensions/$(NAME)@$(DOMAIN)
	@rm -rf ~/.local/share/gnome-shell/extensions/$(NAME)@$(DOMAIN)
	@mv dist ~/.local/share/gnome-shell/extensions/$(NAME)@$(DOMAIN)

clean:
	@rm -rf dist node_modules $(NAME).zip

You can now run make to compile your code and generate the files extension.js and prefs.js inside the dist folder. If needed, it will install the dependencies using npm install.

make pack will generate a file my-extension.zip which you can upload for review. It will compile the code and the schema, if needed, and copy the schemas folder and the metadata.json file into the dest folder before zipping it.

make install will copy the files to the extensions folder. If you logout and back in it should appear in the Extension Manager app.

Finally, make clean removes all generated files.

MIT Licensed | GJS, A GNOME Project