TypeScript
There are different ways to set up TypeScript support for a webpack-based project. The main choice is between using a webpack loader or a Babel preset. Let's look at the pros and cons.
Webpack TypeScript loader #
A webpack loader runs entirely within the webpack build process. This enables performance improvements such as caching, offloading work to separate processes, and parallel execution. However, you end up running two compilers: TypeScript and Babel. The first converts TypeScript code into JavaScript. The second converts that JavaScript for the targeted browsers (via your Browserslist). This slows the overall process and requires configuring two compilers to work together in webpack. Developer experience also depends on how quickly errors surface: when a file changes, recompilation blocks until all TypeScript errors are resolved.
Babel preset #
On the other hand, a Babel preset runs entirely in memory inside the Babel loader, so the optimizations above don't apply. But we get different benefits in exchange. Babel doesn't use the results of the TypeScript compiler; it removes TypeScript syntax altogether. The role of the TypeScript compiler is solely to type-check the code. This makes the overall process faster. We can type-check files separately using the TypeScript CLI, or run the TypeScript compiler in watch mode completely separately from Babel. This gives more flexibility while keeping the webpack configuration simpler.
For the rest of the guide, we will use the Babel preset approach.
The less goes into your webpack configuration, the easier it is to upgrade to the next webpack version.
Dependencies #
@babel/preset-typescript- Babel preset for the TypeScript type-checking executioneslint- TypeScript/JavaScript files linting library@typescript-eslint/eslint-plugin- ESLint plugin for TypeScript@typescript-eslint/parser- TypeScript parser for ESLint. It builds an AST for ESLint to understand TypeScript codebabel-plugin-module-resolver- custom JavaScript module resolver for Babel. It instruments Babel to understand custom file and folder 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 in the initial setup where we created TypeScript configuration for ts-node to run webpack, we are going to create a similar configuration for the TypeScript compiler to process application source files. The following command creates a TypeScript configuration in the root of the project:
npx tsc --init
tsconfig.json #
Open the created tsconfig.json file 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 to have all the options explained in comments.
The
outDiroption does not need to be enabled and can be commented out. If, in the future, there is a need to generate TypeScript definition files or transpiled JavaScript, it guides the 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 the source rootsrcfolder.paths- entries that are used within the application source code with a custom alias setup. We created two aliases,@appand@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 configuration. Add the following scripts to the package.json scripts section:
"type-check": "tsc --noEmit",
"type-check:watch": "npm run type-check -- --watch"
If your setup does not need TypeScript to emit output assets, you can move the
noEmitoption to the 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 browserparser- specifies the TypeScript source code parser for ESLintplugins- instruments ESLint to run the TypeScript parserextends- extends basic ESLint rules with @typescript-eslint package recommended rulesparserOptions- instruments the parserecmaVersion- 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 usage of semicolons only where absolutely necessary.
rules- rules override sectionsemi- include semicolon only when neededlinebreak-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
webpackfolder for the linting step. If the intention is to lint TS/JS files under thewebpackfolder as well, do not add awebpackentry to the.eslintignorefile.
Add the following scripts to the package.json scripts section:
"lint": "eslint './src/**'",
"lint:fix": "npm run lint -- --fix"
lint- runs the linting processlint:fix- automatically fixes linting errors 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 checksbabel-plugin-module-resolver- configures Babel to understand the same aliases we configured with TypeScript earlier. The TypeScript step does nothing with the aliases themselves—only checks that relative files exist—whereas Babel rewrites all imports back to original file paths before passing compilation results to webpack.
Webpack configuration #
As discussed, using TypeScript via a Babel preset keeps the webpack configuration minimal. We just need to make sure webpack recognizes the *.ts extension and routes matching files 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 a Babel loader to include the 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 a 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 a file src/app/feature/index.ts with the contents:
export default (): string => 'feature'
src/index.ts #
Create a 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())