Tree shake Lodash with Webpack, Jest and Typescript
So recently, I’ve started to do payload optimizations within one of our large web apps at work. During that process, I came across very serious issue, well, with lodash while achieving to have everything running smoothly ( tree shaking, jest unit tests and correct type definitions )
I had 3 goals:
- production bundle with just those lodash functions, that are used ( not the whole library )
- typescript valid code without errors ( correct lodash typings )
- all tests passing
Our stack used at that particular project is following:
- preact + preact-compat ( renderer )
- redux + redux-observable + rx ( state management )
- axios ( http req/res )
- typescript 2.x ( for both type checking and transpiling to ES5)
- webpack 2.x ( for bundling )
- jest 20.x + ts-jest ( unit testing + snapshots )
- lodash 4.x ( just few functions for some functionality )
- and some other 3rd party libs…
Backstory
Webpack is ES2015 modules aware, so we are not transpiling import/export
to ES5. This is achieved by having tsconfig.json
config like this:
{ module: es2015, target: es5 }
Lodash doesn’t ship with types unfortunately, so we have to install them from npm, via yarn add -D @types/lodash
With this setup, we have now lodash ready to go in our typed javascript. yasss!
Okay, so let’s say, we wanna leverage _.get
for obtaining values by path, from complicated object ( if you’re doing this often === sign of code smell that your data are badly structured ).
Let’s look at some example from our codebase
Note: following example is highly contrived, just for demonstration purposes of using lodash.
import { get } from 'lodash'import { usersService } from '../core'const main = () => {
userService
.getAll()
.then(data => get(data.response,'[3].address.zip'))
.then(userZip => dispatch(userZipReceived(userZip))
}main()
Now when we run webpack --env.prod
for creating production minified and tree shaked bundle, there is some huge discrepancy.
130kb of minified lodash in your bundle ( WHAT?! ) just for using one function from the library…
In theses situations I like to use a very accurate phrase coined by Martin Probst from angular team used during chaotic angular 2 RC release phase:
OH NO! PANIC!
Keep calm yo, there is a solution for sure! lodash-es ! oh is it?
Okay so yarn add lodash-es && yarn add -D @types/lodash-es
lodash-es stands for, you guess it, es2015 modules aware lodash, so now, there is nothing in our/webpack way to get proper tree shaking!
Let’s run build and profile our bundle with webpack-bundle-analyzer
OH NO PANIC #2! SAME HUGE SIZE !
So can I tree shake lodash or what ???
Let’s say there are multiple solutions…. huh… multiple you say?!
there are 3 solutions…
Solution 1 ( non feasible for our use-case )
After little googling you will find this issue https://github.com/webpack/webpack/issues/1750 which will tell you that you have to use babel-plugin-lodash
for some magic transformation under the hood. Easy right?
Oh wait we are not using babel, so let’s modify our build pipeline by introducing all babel dependencies and plugins for transpilation and use typescript for type checking ( yes you can do just that ), and you are good to go.
Ofc, don’t remember to update your jest configuration appropriately cowboy!
Sad Truth ( for lodash users ):
Without using babel
and babel-plugin-lodash
there is currently no way how to tree shake lodash, if you wanna use concise import without subpaths => import { get } from 'lodash-es'
Solution 2 ( no babel )
Use subpath imports from lodash with Typescript
install
lodash, @types/lodash, @types/lodash-es
Just useimport get from 'lodash/get'
and you’re good to go!
Whoops not so easy cowboy !
You will get TS errors and your test will start to fail like this:
Why errors ?
→ TS errors:
bad subpath typings provided by @types/lodash
→ Test errors:
ts-jest transforms our TS code to ES5 + commojs module format for Jest, so import get from 'lodash/get'
is transiled to commonjs style with default
property, which of course, doesn’t exist within lodash source ( there is just module.exports = get
.
How to solve this ?
Solution is quite easy, you need to add following config to tsconfig.json:
{
"allowSyntheticDefaultImports": true, "baseUrl": ".",
"typeRoots": [
"node_modules/@types",
"manual_typings"
],
"paths": {
"lodash/*": [
"node_modules/@types/lodash-es/*"
],
},}
- “typeRoots” && “paths”:
by using typeRoots we are telling Typescript compiler to look to different folders for 3rd party typings. By default TS looks for types at node_modules/@types.
paths are used for explicitly overriding path resolution algorithm. In our case we are overriding lodash module path to lodash-es which has correct subpath Type definitions. Note that you have to use “baseUrl” when using path
- “allowSyntheticDefaultImports”: true Allow default imports from modules with no default export.
Result:
tree shaked lodash in production bundle
Solution 3 ( no babel )
Use subpath imports from lodash-es with Typescript
install
lodash-es, @types/lodash-es
Just useimport get from 'lodash-es/get'
and you’re good to go!
Whoops not so easy cowboy ! ( huh Deja vu ? )
This time no TS errors, nice!
but your test will start to fail like this:
Why errors ?
→ Test errors:
We are now using lodash-es which uses ES2015 modules. Jest by default doesn’t apply transformers to any node_modules package for performance reasons, so yeah import
really isn’t commonjs or valid ES5 code.
How to solve this ?
We need to transpile lodash-es source code explicitly in jest and enable vanilla JS files transpilation by typescript
jest.config.json:
"transform": {"^.+\\.(j|t)sx?$": "<rootDir>/node_modules/ts-jest/preprocessor.js",},"transformIgnorePatterns": [
"<rootDir>/node_modules/(?!lodash-es/.*)"
],
- ^.+\\.(j|t)sx?$
We have to transpile also .js files not just Typescript
We have to white list lodash-es within transformIgnorePatterns
We have now setup jest, last thing to do is to tell Typescript to transpile .js
files
tsconfig.json: — no excessive hacks and overrides like in previous solution. LGTM !
{
"allowJs": true
}
Result:
tree shaked lodash in production bundle
lodash size ( non gzipped ) in our prod bundle ~= 30kb which is 100kb less than before! We are done here!
Takeaways:
- We ended up using solution #3( although on bundle-analyzer it looks like that solution #2 has smaller footprint, but with solution 3 our final vendor bundle is 20kB smaller ). Also we prefer to use es2015 ready modules for further optimizations and another benefit is that we don’t have to hack types.
- Jest is freakin’ awesome!, but! you have to always double config stuff (webpack and jest ) which feels MEH ( now you need to hire both webpack config senior developer and jest config senior dev :D )
- Always remember that when you consume npm package which has “module” field -> webpack understands that, but if the package is missing proper UMD/commonjs you will have to transpile it as well within Jest
- 3rd party types are not always excellent ( also DefinitelyTyped type versions doesn’t always match with used library version, so you’ve absolutely no guarantees, that you have actual and correct types for your particular version of 3rd party package => for that reason I always prefer 3rd party libs that are written in Typescript or ship type definitions directly. In edge cases you can modify/create custom types and use technique that was introduced in Solution 2)
- Have to solve all this issues in 2017, feels like a huge tax payed for using only few lodash functions. I’ll probably use other, tree shake ready and more functional solution on next project ( yup I’m looking at you Ramda ! )
YES I KNOW: lodash/fp is solution as well, but there are absolutely no type definitions for that currently ( June 2017 ), so a no go for me and my team
Hope this will be useful for anyone who may struggle with similar problem now or in the future.
Happy hunting and may the force be with you folks! Cheers!
UPDATE August 2017:
Webpack ( Tobias Koppers ) is rockin’ hard and with the new pure-module feature, lodash will be tree-shake able by default, so no sub paths import will be needed.
→ import {get,pick} from 'lodash-es'
will just work! more info :
UPDATE January 2019:
TypeScript added esModuleInterop which properly resolves non standard default imports, which acts similarly like babel. Also ts-jest went through various changes, so the ultimate solution is now much easier than before ❤️:
// install lodash (both commonjs nad es2015 modules) with types
yarn add lodash-es
// commonjs lodash will be used for tests
yarn add -D lodash @types/lodash-es
// jest.config.jsconst config = {
preset: 'ts-jest',
moduleNameMapper: {
// we'll use commonjs version of lodash for tests 👌
// because we don't need to use any kind of tree shaking right?!
'^lodash-es$': '<rootDir>/node_modules/lodash/index.js'
},
}
// tsconfig.json
{
compilerOptions: {
// other config settings
"esModuleInterop": true,
// no allowJs needed 💥🚨👌
}
}
Now following works as expected:
import {get,pick} from 'lodash-es'