Skip to main content

Typescript

There are different ways to set up TypeScript support for the webpack based project. But the main difference is between using another webpack loader or a babel preset. Let's look at the pros and cons.

Webpack typescript loader

Webpack loader runs entirely within the webpack execution cycle. This gives the ability of implementing different compilation performance improvements like caching, separate process offloading, parallel execution, etc. But we have to run two compilers: TypeScript and Babel. First one converts TypesScript code into JavaScript code. Second one coverts the results of the previous one into JavaScript code that can run in browsers under configured browser list. This makes the entire process slower. We also need to configure two compilers working together within the webpack. Developer experience is mostly influenced by how the developer gets to see compilation errors. Every time a file is changed the whole compilation will start over not producing any results until all the TypeScript errors are resolved.

Babel preset

On the over hand, babel preset runs entirely in the memory dedicated to the babel loader itself. So no optimizations mentioned above. But we get different things in exchange. Babel does not use the results of the TypeScript compiler. It removes TypeScript code all together. So the role of the TypeScript compiler is solely to type check the code. This makes the entire process faster. We can type check files separately using just TypeScript CLI when need to. Or we can run TypeScript compiler in a watch mode compliantly separately from babel. So we get a bit more room to play with our set up, yet we do not need to configure two separate compilers with the webpack.

For the rest of the guide, we are going to be using babel preset approach.

The less goes into you the webpack configuration, the easier it is to upgrade to the next webpack version.

Dependencies

  • @babel/preset-typescript - babel preset for the TypeScript type checking execution
  • eslint - TypeScript/JavaScript files linting library
  • @typescript-eslint/eslint-plugin - eslint plugin for TypeScript
  • @typescript-eslint/parser - TypeScript parser for eslint. It builds AST for the eslint to understand TypeScript code
  • babel-plugin-module-resolver - custom JavaScript module resolver for babel. It instruments babel to understand custom files and folders aliases.

One line setup

npm i @babel/preset-typescript eslint @typescript-eslint/eslint-plugin \
@typescript-eslint/parser babel-plugin-module-resolver -D --save-exact

TypeScript configuration

Just like we created TypeScript configuration for the ts-node to run webpack during initial setup, we are going to create similar configuration for the TypeScript compiler to process application source files. Following command creates TypeScript configuration in the root for the project:

npx tsc --init

tsconfig.json

Open tsconfig.json file created and change its contents to:

{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
"noUnusedLocals": true, /* Report errors on unused locals. */
"noUnusedParameters": true, /* Report errors on unused parameters. */
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./src", /* Base directory to resolve non-absolute module names. */
"paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
"@app": ["./app"],
"@app/*": ["./app/"],
"@utils": ["./utils"],
},
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}

The benefit of creating the config file this way is the ability of having all the options explained in comments.

outDir option does not need to be enabled and could be commented out. If in future, there is a need to generate TypeScript definition files or transpiled JavaScript code, it guides output directory structure.

TypeScript understands custom module resolution for files and folders (module path mapping). To configure this option, we have to include two parameters:

  • baseUrl - the root of the custom module resolution. In our case it's a source files root src folder.
  • paths - entries that are used within the application source code with custom alias setup. We created two aliases @app and @utils that map to corresponding folders under the source root.

To configure an alias for the sub-folders of a particular folder, we have to use a wildcard. In our example it was an alias for the app folder: "@app/*": ["./app/"].

We can run TypeScript compilation with no additional babel config setup. Add following scripts to the package.json scripts section:

"type-check": "tsc --noEmit",
"type-check:watch": "npm run type-check -- --watch"

If your particular setup does not need a potential TypeScript output assets generation, you can move noEmit option to tsconfig.json file permanently.

Eslint configuration

.eslintrc.js

Create a file .eslintrc.js in the root of the project:

module.exports =  {
env: {
browser: true
},

parser: '@typescript-eslint/parser', // parser

plugins: ['@typescript-eslint'],

extends: [
'plugin:@typescript-eslint/recommended', // recommended rules
],

parserOptions: {
ecmaVersion: 2018, // modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
},

rules: {
semi: ["error", "never"],
"linebreak-style": ["error", "unix"]
}
};
  • env.browser - specifies the execution environment as a browser
  • parser - specifies TypeScript source code parser for eslint
  • plugins - instruments eslint to run TypeScript parser
  • extends - extends basic eslint rules with @typescript-eslint package recommended rules
  • parserOptions - instruments the parser
  • ecmaVersion - latest ES, the same as the TypeScript configuration target option (esnext)
  • sourceType - enable usage of ES modules

As an example of custom rules, there is one rule included. The semicolon rule restricts usages of semicolon only where it is absolutely necessary.

  • rules - rules override section
  • semi - include semicolon only when needed
  • linebreak-style - enforce UNIX systems line endings

As an example of ignoring files from the linting check let's create a file .eslintignore in the root of the project:

webpack

With this config we are ignoring the contents of the webpack folder for the linting step. If the intention is to lint ts/js files under the webpack folder as well, do not add a webpack entry to the .eslintignore file.

Add following scripts to the package.json scripts section:

"lint": "eslint './src/**'",
"lint:fix": "npm run lint -- --fix"
  • lint - runs linting process
  • lint:fix - automatically fixes linting error where possible

Babel configuration

So-far, TypeScript configuration was completely decoupled from babel configuration. This helps in local dev scenarios. However, for the final project assets generation we need to configure babel to be aware of the TypeScript type checking step. Change the contents of the babel.config.js file:

module.exports = api => {

api.cache(true)

return {
presets: [
["@babel/preset-env", {
"useBuiltIns": "usage",
corejs: 3,
}],
"@babel/typescript"
],

plugins: [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-object-rest-spread",
["@babel/plugin-transform-runtime", {
corejs: 3,
useESModules: true
}],

[require.resolve('babel-plugin-module-resolver'), {
root: ["."],
alias: {
"@app": "./src/app",
"@utils": "./src/utils"
}
}],
]
}
}
  • @babel/typescript - additional preset to run TypeScript type checks
  • babel-plugin-module-resolver - configures babel to understand exactly the same aliases we configured the TypeScript with earlier. TypeScript step does nothing with the aliases themselves just checking if relative files exists, where babel is going to rewrite all the imports back to original file paths before passing compilation results to the webpack.

Webpack configuration

As we discussed, TypeScript usage as a babel preset keeps webpack configuration at minimum. We just need to make sure webpack understands an additional file extension to *.ts file and routes corresponding file contents to the appropriate loader.

webpack/parts.ts

Change webpack/parts.ts contents:

import path from 'path'
import webpack, { Entry, Output, Node, Resolve, Plugin, Module } from 'webpack'
import HtmlWebpackPlugin from 'html-webpack-plugin';
import HtmlWebpackHarddiskPlugin from 'html-webpack-harddisk-plugin';
import { CleanWebpackPlugin } from 'clean-webpack-plugin'

export const distFolder = () => path.resolve(__dirname, '../dist')

export const getParts = () => ({
context: path.join(__dirname, '../src'),

entry: {
main: './index',
} as Entry,

output: {
path: path.resolve(distFolder(), 'js'),
filename: '[name].bundle.js',
publicPath: '/js'
} as Output,

node: {
fs: 'empty'
} as Node,

resolve: {
extensions: ['.ts', '.js', '.json']
} as Resolve,

module: {
rules: [{
// Include ts/js files.
test: /\.(ts)|(js)$/,

exclude: [
/node_modules/,
],
loader: 'babel-loader',
}]
} as Module,

plugins: [
new webpack.EnvironmentPlugin(['NODE_ENV']),
new HtmlWebpackPlugin({
chunksSortMode: 'auto',
filename: '../index.html',
alwaysWriteToDisk: true
}),
new HtmlWebpackHarddiskPlugin(),
new CleanWebpackPlugin({ verbose: true })
] as Plugin[]
})

We introduced a new resolve section with the file extensions and babel loader to include ts extension.

Sources

src/app/index.ts

Rename src/app/index.js to src/app/index.ts and change its contents to:

function* func (): Generator<void, void, string> {
const result = yield console.log('test')
console.log(result)
}

export default func

src/utils/index.ts

Create file src/utils/index.ts with the contents:

export const toCapital = (value: string): string =>
`${value.charAt(0).toUpperCase()}${value.slice(1)}`

src/app/feature/index.ts

Create file src/app/feature/index.ts with the contents:

export default (): string => 'feature'

src/index.ts

Create file src/index.ts with the contents:

import 'core-js/stable'
import 'regenerator-runtime/runtime'

import func from '@app'
import feature from '@app/feature'
import { toCapital } from '@utils'

const f = func()
f.next()
f.next(toCapital('hello world'))
console.log(feature())

Final version

Reference implementation