Django and Webpack - TypeScript, Babel, and Asset Modules

In this post, we'll learn how to leverage Webpack to use TypeScript, and ES2015+ JavaScript in our Django applications. We'll learn how to use Babel to transpile ES2015+ JavaScript into a format understood by browsers, and we will also learn about Webpack 5 asset modules that allow us to load different assets into our bundles and outputs.

The associated video for this post can be found below.


Objectives

In this post, we'll learn:

  • How to use TypeScript in our Django apps, with Webpack's ts-loader
  • How to use Babel to transform modern JavaScript to a format understood by older browsers using the babel-loader
  • How to work with asset modules to load files, images, fonts, etc

TypeScript and Django

TypeScript is a strongly-typed superset of JavaScript that includes features such as types (obviously!), interfaces, generics, enums and decorators - among many more. Using TypeScript can help with building robust applications, and with the use of types, enables superior tooling to vanilla JavaScript.

TypeScript code is not understood by browsers, so these files must be compiled down to vanilla JavaScript before deployment. Thus, TypeScript can be a very useful aid during development, but importantly, the code must be compiled to JavaScript - and that's where Webpack and the ts-loader come in!

Let's dive in and install TypeScript along with the ts-loader Webpack loader. 

npm install -D typescript ts-loader

The ts-loader works in a similar manner to the css-loader and style-loader from the previous post, but we will be applying this new loader specifically to TypeScript files in our asset folder.

Once we've installed these, add a tsconfig.json file to our project, in the same location as our Webpack configuration and our package.json file. This JSON file determines our settings for using and compiling TypeScript. We're going to base this file on the tsconfig.json configuration from the Webpack docs into this file, however we can remove the JSX line (we're not using JSX yet), as below:

{
    "compilerOptions": {
      "outDir": "./core/scripts",
      "noImplicitAny": true,
      "module": "es6",
      "target": "es5",
      "allowJs": true,
      "moduleResolution": "node"
    }
}

The ts-loader invokes the TypeScript compiler when we build our bundle, and that will handle transpiling the TypeScript down to vanilla JavaScript, based on these options. For more TypeScript configuration options, check out this page.

Now, we can add a TypeScript file to our assets/scripts folder. Add a file called calculator.ts - in here, we'll define some trivial functions to demonstrate the basics of TypeScript.

function add(num1: number, num2: number): number {
    return num1 + num2;
}


function sum(numbers: number[]): number {
    return numbers.reduce((a, b) => a + b)
}

window.addEventListener('load', () => {
    console.log(add(2,2));
    console.log(sum([1,2,3,4,5]));
})

The arguments to these functions have been given a type of number - as you can guess, this indicates that the function expects a numerical value. For the sum function, the argument has a type of number[], indicating that the function should receive an array of numbers.

If we try and call these functions with the wrong type, the enhanced tooling with VSCode should highlight this problem for us - this can help us find errors BEFORE runtime, which is useful! See below for an example, where we call the function with a string rather than a number.


Now, we want this TypeScript code to be in our final bundle (after compilation), so let's try importing this into our entry file. Add the following import:

import './calculator.ts';

Now, let's run Webpack with our npm run dev script. This is not going to work, because Webpack does not know how to deal with TypeScript out of the box - you should see a message such as this:

And that's why we installed the ts-loader! Let's amend our Webpack configuration file now to include some additional settings that'll let us include compiled TypeScript files in our bundle.

To the Webpack config, add this code:

const path = require('path');

module.exports = {
    entry: './assets/scripts/index.js',
    output: {
        'path': path.resolve(__dirname, 'core', 'static'),
        'filename': 'bundle.js'
    },
    resolve: {
        extensions: ['.ts', '...'],
    },
    module: {
        rules: [
            {
                test: /\.css$/i,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /\.ts$/i,
                use: 'ts-loader',
                exclude: '/node_modules/'
            }
        ]
    }
}

We've added a resolve option on lines 10-12, and have added ".ts" to our extensions - Webpack tries to resolve these extensions in the order defined, and here, we add TypeScript's extension before the defaults, which can be specified by the spread operator .... Read more on the resolve.extensions option here.

We also add a new Loader to our module.rules array. This loader tests for files with the TypeScript extension, and uses the ts-loader to transform these files to vanilla JavaScript, for inclusion in our final bundle.

Now, let's test with the new Webpack configuration. Run npm run dev and ensure that the bundle compiles successfully.

If this works, when you load the page at localhost, you should see in the console the output of the add() and sum() functions from our TypeScript files.

Let's add something slightly more interesting. We're going to allow users to add two numbers, and we'll use our TypeScript file to perform the calculation and output a message to the DOM.

To our Django index.html template, add the following code below the Alpine component:

<body>

    ...

    <div>
      <input id="num1" type="number" />
    </div>
    <div>  
      <input id="num2" type="number" />
    </div>

    <button id="addBtn">Calculate Sum</button>

    <p id="result"></p>
    
  </body>

We have two number input fields, and a button that should add the two numbers together. We also have a result paragraph.

Let's add TypeScript code to fetch the two numbers entered, add them up with our custom add() function, and then output the value to the result paragraph. Change the TypeScript file to add this code:

function add(num1: number, num2: number): number {
    return num1 + num2;
}

function sum(numbers: number[]): number {
    return numbers.reduce((a,b) => a+b)
}

window.addEventListener('load', () => {
    
    let addBtn = document.getElementById('addBtn');

    addBtn.addEventListener('click', (e) => {
        let num1 = document.getElementById('num1') as HTMLInputElement;
        let num2 = document.getElementById('num2') as HTMLInputElement;
        let value1 = parseInt(num1.value);
        let value2 = parseInt(num2.value);
        let result = document.getElementById('result');
    
        result.textContent = `${value1} + ${value2} = ${add(value1, value2)}`
    });
})

We've added an event listener on our button - when the click event is fired, our code will fetch the two numbers entered, and TypeScript will enforce the conversion of the entered values to numbers (see the parseInt() function - without that, TypeScript will complain, as by default the values from the input fields are strings).

On line 20, we output the result of the calling the add() function and passing the two numbers from the form. We use a JavaScript template string to make this easy.

So that wraps this first section up. We're now able to use TypeScript within our Django applications! TypeScript can be very useful when developing front-end code, and with our current setup, you can mix and match TypeScript with vanilla JavaScript in your codebase.

Transpilation with Babel

We'll make this section short and sweet. We're going to use Babel to compile newer EcmaScript 2015+ code to older JavaScript that is understood by all browsers.

Babel is a tool that allows us to compile these newer features and proposals down to code that browsers understand, and this allows you to use these nice, shiny, new features of JavaScript in your development.

Babel works by using plugins to transform the code - for example, the plugin-transform-arrow-functions plugin, which transforms ES2015+ arrow functions to normal JavaScript function definitions. With that plugin, the following code:

var a = () => {console.log("hello"};

Would become:

var a = function() {console.log("hello"};

There are LOTS of plugins available. You can find a list here. Rather than defining them all individually, there are groups of plugins combined together for common tasks - these are called presets.

The most commonly-used preset is the @babel/preset-env, which takes care of compiling ES2015+ features to older JavaScript. We're going to install Babel and this preset into our environment with the following command:

npm install -D @babel/core @babel/cli @babel/preset-env

We are going to be following this guide here. After installation, we'll create a babel.config.json file that matches what is in the documentation. Create that alongside the Webpack configuration file, and add this code:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "edge": "17",
          "firefox": "60",
          "chrome": "67",
          "safari": "11.1"
        },
        "useBuiltIns": "usage",
        "corejs": "3.6.5"
      }
    ]
  ]
}

In a real application, you can set the browser targets to whatever you want to support, and Babel will take care of transpiling your ES2015+ code down to a version of JavaScript that these will understand.

Now, let's see the transpilation in action. For now, we're going to simplify our Webpack entry point, index.js, by commenting out most of the code we've written, and replacing it with some ES2015+ statements. See below:

let x = 1_000_000;   // "let" keyword [ES2015] and numerical literal separators [ES2021]
console.log(x)

let y = () => { console.log("from function")}; // "let" and arrow functions [both ES2015]

If we save this file and run our build script, we should see the generated bundle contains the code as we define it. We're now going to add another Webpack loader - the babel-loader - to add transpilation of ES2015+ statements as part of our build pipeline.

First, let's install the babel-loader with the npm install -D babel-loader command.

Next, we'll add the loader to our Webpack configuration file:

const path = require('path');

module.exports = {
    entry: './assets/scripts/index.js',
    output: {
        'path': path.resolve(__dirname, 'core', 'static'),
        'filename': 'bundle.js'
    },
    devtool: 'inline-source-map',
    resolve: {
        extensions: ['.ts', '...'],
    },
    module: {
        rules: [
            {
                test: /\.css$/i,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /\.ts$/i,
                use: 'ts-loader',
                exclude: '/node_modules/'
            },
            {
                test: /\.js$/,
                exclude: '/node_modules/',
                use: {
                  loader: 'babel-loader',
                  options: {
                    presets: ['@babel/preset-env']
                  }
                }
              }
        ]
    }
}

The third loader in our module.rules array, on lines 27-36, defines the Babel loader. We are applying this to JavaScript files, as defined by the test option, and are excluding node_modules packages from transpilation (otherwise, we'd potentially transpile lots of third-party code for no reason).

Then, in the use block, we set the babel-loader and define the single preset that we'll use - the @babel/preset-env which transforms ES2015+ statements to a version that browsers can understand.

Note also that we've added the devtool option. We set that to inline-source-map to allow us to see the output code in the bundle inline.

Once we've added this, we can test the build pipeline. Re-run the npm run dev command, and inspect the output. For me, the code has been changed to the following in the bundle output by Webpack:

var x = 1000000;
console.log(x);

var y = function y() {
  console.log("from function");
};

So our modern JavaScript statements that we're using in development have been transpiled to older code, and this is done as part of our build pipeline using Babel and the babel-loader. Nice!

There's more we can do with Babel. We'll see how to use it with React in a later post.

Before we go on, let's uncomment everything in our entry-file index.js. and remove the above Babel testing code. We can also clean things up a little by deleting the Babel configuration file and removing the babel-loader. We'll no longer need these at the moment. You can also remove the devtool option from the configuration file.

Let's move on to asset modules to close off the post.

Asset Modules

Asset modules are new in Webpack 5, and they allow you to work with asset files such as images, icons and fonts - without having to install and use additional loaders.

This is nice - as you'll notice, we've installed quite a few loaders and presets. You may need only some of these, depending on your requirements, but asset loaders are native to Webpack, so no other packages are required!

Let's see how we can use asset modules to bring images from the assets folder into our app.

Firstly, let's get an image that we can use. Grab any image from here, and save it to the assets/images folder (of course, you can use any image you like for this!). If you don't have the images folder, create it now. I'll call the image test-image.jpeg.

Now, during our build process, we want to output this file to our build directory, so that the asset is available on the server. We're going to amend the webpack.config.js file to add the asset module that will process the image files. See below, lines 27-30:

const path = require('path');

module.exports = {
    entry: './assets/scripts/index.js',
    output: {
        'path': path.resolve(__dirname, 'core', 'static'),
        'filename': 'bundle.js'
    },
    resolve: {
        extensions: ['.ts', '...'],
    },
    module: {
        rules: [
            {
                test: /\.css$/i,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /\.ts$/i,
                use: 'ts-loader',
                exclude: '/node_modules/'
            },
            {
                test: /\.(png|svg|jpg|jpeg|gif)$/i,
                type: 'asset/resource',
            },
        ]
    }
}

We've added a new object to our module.rules configuration. The test is for any image file, and the type is set to asset/resource.

Let's now import the image into our index.js file, which is our Webpack entry point. Add the following import at the top:

import TestImage from '../images/test-image.jpeg';

Let's now see what happens when we run the Webpack build. Execute the npm run dev command.

You should see a file has been dropped into your output directory, i.e. the core/static directory, alongside the bundle that was generated.

With the asset/resource type, when we process a file it will be dropped into the output directory, and in our source-code file the imported image - here, called TestImage - will have the final URL of the asset in our output directory. That means we can add images to the DOM dynamically and reference the image URL via this imported object.

Let's see this - add the following code to index.js:

import TestImage from '../images/test-image.jpeg';

window.addEventListener('load', () => {
    const myImg = new Image();
    myImg.src = TestImage;
    myImg.style.width = "200px";
    myImg.style.height = "200px";

    document.body.appendChild(myImg);
})

Here, we import the image from the image file, as before. Then, after the DOM content has loaded, we execute an event listener that creates an Image() object in our JavaScript code, and sets its .src attribute equal to what we've imported

This works because the imported object contains the final URL of the file that we've copied to the output location. Webpack handles injecting that URL into the .src attribute, ensuring that we can transparently copy our image assets to the build directory and set URLs correctly. Quite nice!

You can add as many images as you'd like to the assets/images directory. Any images that you import into your source code files will be copied to your output directory, and you can reference the URL of these files when importing the objects in your source-code. That's the power of the asset/resource asset module!

This setup will work with any file-type - for example, with fonts. See this guide here for more.

There are a few other strategies for working with file assets, such as importing files as a string with the asset/source module, and exposing a data-URI of the asset without copying it to the output directory with the asset/inline module. Read more here.

Summary

In this post, we've learned how to setup TypeScript and Babel in a Django project, and have learned how to use asset modules to work with files in our Webpack builds.

We're going to cover some advanced topics in the next post, such as generating multiple bundles, adding hashes to filenames for caching, Webpack plugins, and using the HtmlWebpackPlugin.

After we've covered these topics, we'll dive into the following Django server environments (with no separate client-side app) in future posts:

  1. Setting up HTMX, Tailwind and Alpine.js environment with Webpack
  2. Setting up React.js environment with Webpack 
  3. Setting up Vue environment with Webpack
  4. Setting up Svelte environment with Webpack

Stay tuned, and thanks for reading!

If you enjoyed this post, please subscribe to our YouTube channel and follow us on Twitter to keep up with our new content!

Please also consider buying us a coffee, to encourage us to create more posts and videos!

;