March 14, 2019/by Dave Macaulay

How to use TypeScript & ES6 in your Magento module using Babel

I love new tech, I always want to be use the newest syntaxes and tools to improve my developer experience and work. I was introduced to TypeScript and I fell in love with the concept and how this could vastly improve large scale JavaScript projects. I pushed and pulled and managed to convince the team to include this technology in a Magento bundled extension.... Page Builder!

Along this journey we had many road bumps, mainly due to combining a very new transpiled language with the way Magento currently handles its JavaScript. We ended up opting for an "in module" solution, which doesn't require the core Magento application to process the TypeScript. We also didn't want to complicate our build process, so we also opted to commit the compiled JavaScript into the git repository, we're not proud of this, but it works. In your projects you can certainly work around this by including another build step, but sadly we didn't have that luxury.

Configuration Files

We need to setup some simple file structure, this ensures we have the relevant files and configuration for these tools. Naturally this requires you to have already setup a simple Magento module with the relevant registration.php, module.xml etc.

The files listed below should sit within your root directory unless otherwise specified.

tsconfig.json

Firstly lets create our tsconfig.json configuration file. This file contains configuration informing TypeScript how to behave in our particular project, this can be modified to your projects needs. The below serves as an example of what we use here at Magento for Page Builder modules.

{
    "compilerOptions": {
        "strictNullChecks": true,
        "module": "esnext",
        "target": "es2015",
        "allowJs": true,
        "noImplicitAny": true,
        "pretty": true,
        "allowSyntheticDefaultImports": true,
        "typeRoots": [
            "./node_modules/@types/",
            "./node_modules/@magento/"
        ],
        "lib": [
            "es2015",
            "es2015.iterable",
            "es2015.promise",
            "dom",
            "es7"
        ]
    },
    "include": [
        "./**/*.d.ts",
        "./**/*.ts"
    ],
    "exclude": [
        "node_modules",
        "**/*.spec.ts"
    ]
}

tslint.json

It wouldn't be a good idea to start writing large amounts of TypeScript without some linting, this helps ensure our code conforms to some basic standards. Again this can be configured to your requirements and preferences.

{
    "defaultSeverity": "error",
    "extends": [
        "tslint:recommended"
    ],
    "jsRules": {},
    "rules": {
        "interface-name": false,
        "no-console": false,
        "object-literal-sort-keys": false,
        "one-line": false,
        "no-empty-interface": false
    },
    "rulesDirectory": []
}

package.json

This is where the real magic happens, we declare are dependencies along with a number of scripts to enable us to develop, lint, format and build our TypeScript assets.

{
    "name": "@your-name/module-example-typescript",
    "version": "1.0.0",
    "description": "Magento TypeScript module",
    "scripts": {
        "test:static": "tslint --project tsconfig.json",
        "test:static:log": "set -o pipefail; npm run test:static | tee tslint-errors.txt",
        "ts:errors": "tsc --noEmit",
        "test:errors:log": "set -o pipefail; npm run ts:errors | tee tsc-errors.txt",
        "ts:errors:watch": "npm run ts:errors -- -watch",
        "ts:lint": "tslint --fix --project .",
        "ts:build": "babel view/adminhtml/web/ts/js/ --out-dir view/adminhtml/web/js/ --extensions '.ts,.tsx' --source-maps",
        "ts:watch": "npm run ts:build -- --watch",
        "start": "concurrently -n 'compiler,errors' -c 'green,red' 'npm run ts:watch' 'npm run ts:errors:watch'"
    },
    "devDependencies": {
        "@babel/cli": "^7.0.0",
        "@babel/core": "^7.0.0",
        "@babel/plugin-proposal-class-properties": "^7.3.0",
        "@babel/plugin-syntax-object-rest-spread": "^7.0.0",
        "@babel/plugin-transform-modules-amd": "^7.0.0",
        "@babel/preset-env": "^7.3.1",
        "@babel/preset-typescript": "^7.0.0",
        "@comandeer/babel-plugin-banner": "^4.1.0",
        "concurrently": "^4.1.0",
        "tslint": "^5.12.1",
        "typescript": "^3.2.4"
    },
    "author": "Your Name",
    "dependencies": {
        "@babel/polyfill": "^7.0.0"
    }
}

Let's take a moment to breakdown some of the aspects of this file, firstly we have the scripts second. These can all be ran via the standard syntax: npm run command or npx command.

  • test:static runs our TSLint configuration on the code base to report any issues.
  • test:static:log this runs our TSLint configuration and creates a text file on disk, this can be helpful to include within your build process.
  • ts:errors displays any TypeScript errors from the TSC compiler, as we're using Babel to compile our TypeScript we do not receive errors from babel.
  • ts:errors:log similar to the static log function, this writes the errors to a text file for usage in builds.
  • ts:errors:watch watches for errors from the TSC compiler, useful when developing.
  • ts:lint allow easy access to TSLints fix option, automatically fixing any formatting issues you have within your code.
  • ts:build this uses babel, along with our babel configuration to compile our TypeScript into the desired directory. Currently this configuration only compiles adminhtml code as per the directory specified within the command. If you wish to build in another area of your module you'll need to update or extend this command.
  • ts:watch runs the above build command with the watch argument included, this allows for file changes to automatically be compiled.
  • start this uses concurrently to run both the babel TypeScript watch command along with the errors watch command. This enables you to receive compiler errors whilst still using the incredibly quick Babel TypeScript plugin.

.babelrc.js

We place our Babel configuration within the TypeScript directory of the area we wish to utilise the tooling within, as earlier in this example we're only supporting the adminhtml directory, so we shall place this file within view/adminhtml/web/ts. We opted to add a new subdirectory within the web folder to separate the TypeScript and JavaScript.

const tsDirectory = 'app/code/YourNameSpace/ExampleTypeScript/view/adminhtml/web/ts/';
const moduleName = 'YourNameSpace_ExampleTypeScript';

module.exports = {
    passPerPreset: true,
    presets: [
        {
            plugins: [
                ['@babel/plugin-proposal-class-properties', {
                    loose: true
                }],
                '@babel/plugin-transform-modules-amd',
                './babel/plugin-amd-to-magento-amd',
            ]
        },
        [
            '@babel/preset-env',
            {
                loose: true,
                targets: {
                    browsers: ['last 2 versions', 'ie >= 11']
                },
                modules: 'amd'
            }
        ]
    ],
    plugins: [
        '@babel/plugin-transform-typescript',
        ['./babel/plugin-resolve-magento-imports', {
            path: tsDirectory,
            prefix: moduleName
        }],
        ['@comandeer/babel-plugin-banner', {
            'banner': "/*eslint-disable */\n"
        }],
        '@babel/plugin-syntax-object-rest-spread'
    ],
    ignore: [
        '/**/*.d.ts',
        '/**/*.types.ts',
    ]
};

As you can see from our @babel/preset-env configuration we require the compiled code to be compatible with last 2 versions of recent browsers along with IE 11 up, these are the current supported browsers by core Magento, you can customise this to your projects requirements. We also have to specify the module loader as AMD as that's the solution Magento uses with RequireJS.

There are two constants are the top of this file which require you to specify the TypeScript directory along with the module name. This is required due to the custom Magento plugins we've created to ensure the compiled code is compatible with Magento.

Babel Plugins

We have created two custom Babel plugins to modify our TypeScript output to be fully compatible with Magento, there were two unique problems we had to solve to enable good compatibility, in an ideal world you'd want a build tool such as WebPack handling this, but Magento doesn't yet give us that luxury.

You'll need to include both of these plugins manually within your module as we have not

plugin-resolve-magento-imports

You can view the entire contents of this module on GitHub here. This module translates relative ES6 imports into their Magento counterparts, this is why we required your Magento module name in the .babelrc.js file above.

import Collection from "./collection";

Is converted to the following by this plugin:

define(["YourNameSpace_ExampleTypescript/collection", function (collection) {

});

plugin-amd-to-magento-amd

This plugin is also available on GitHub here. The other interesting problem we faced was Magento's JavaScript out the box were not fully compliant AMD modules, as they directly returned their functionality. This meant our compiled JavaScript contained default which Magento could not understand.

This plugin transforms the output to omit all usage of the default property and instead follow Magento's implementation of returning the functionality directly. This allows for the compiled code to import Magento classes along with being utilised within other aspects of Magento.

You are ready to TypeScript!

Now you've configured the configuration files within your module you're ready to start writing your TypeScript, in our case this is within view/adminhtml/web/ts. When you wish to start development you'll need to navigate into your module's directory and run npm run start this will then start the development tools, you'll be able to see any compiler errors within the terminal window. You'll also be able to see the JavaScript output in the configured output directory, in our case that's view/adminhtml/web/js.

As I outlined earlier it's less than ideal to have committed compiled code within your modules so if you're able to I'd highly recommend including a build step which does the build for you and excluding the directory within git.

Back.