While working recently on an NPM library built with Rollout, I noticed that the folder structure changed, and some TypeScript definition files, the .d.ts ones, weren’t in the final build where they were supposed to be. After fixing this, I created an integrity checker that ensured that all TypeScript definition files were there they should be and were run by a GitHub Action after the final build.
Let’s quickly refresh what TypeScript Definition Files documentation are:
TypeScript has two main kinds of files.
TypeScript: Documentation – Type Declarations – .d.ts files.ts
files are implementation files that contain types and executable code. These are the files that produce.js
outputs, and are where you’d normally write your code..d.ts
files are declaration files that contain only type information. These files don’t produce.js
outputs; they are only used for type checking. We’ll learn more about how to write our own declaration files later.
It’s important to distribute these when you’re creating an NPM package so the host app can use these declarations for type checking. Types are one of the key aspects that differentiates TypeScript from JavaScript after all.
Since TypeScript .d.ts
files are found on each folder where the source files are, the idea of this integrity checker was to go through the source code folders, build a tree structure, and then go through the files in the build, checking that the definition files were created.
Building a tree based on the TypeScript source files
We use a recursive function to create the tree that goes through each folder. The structure for the file tree will be simple:
class TreeTS {
route
children
constructor(route) {
this.route = route
this.children = []
}
}
It’s a recursive collection of nodes. We’ll store each path and the child nodes (directories or files) found in it for each one. However, the complex part comes when we crawl the source files and populate our tree, and we’ll have to keep into account a few factors:
- we’ll want to start in a specific directory where we have our TypeScript files, like
/src
- we might also want to exclude some directories, like those for Jest tests, translation files, Storybook stories, and so on
- there’s a major difference between a directory that contains a single
.ts
or.tsx
file, and one that contains several.ts
files. In the first case, TypeScript will create anindex.d.ts
, and in the second, will create several d.ts files, each one named after the source.ts
file
const fs = require('fs')
const path = require('path')
const excludeDirectories = ['stories', '__jest__', '__mock__', 'translations']
const directoriesWithNamedDts = ['hooks', 'lib', 'i18n']
const fileTypes = ['.ts', '.tsx']
function buildTree(rootRoute) {
const root = new TreeTS(rootRoute)
const stack = [root]
while (stack.length) {
const currentNode = stack.pop()
if (currentNode) {
const children = fs.readdirSync(currentNode.route)
for (const child of children) {
const childRoute = `${currentNode.route}/${child}`
const childNode = new TreeTS(childRoute)
if (
fs.statSync(childNode.route).isDirectory() &&
!excludeDirectories.some((d) => childNode.route.indexOf(d) >= 0)
) {
stack.push(childNode)
currentNode.children.push(childNode)
}
if (
directoriesWithNamedDts.some((d) => childNode.route.indexOf(d) >= 0) &&
fileTypes.includes(path.extname(childNode.route))
) {
currentNode.children.push(childNode)
}
}
}
}
return root
}
Checking the build for TypeScript definition files
After building the tree referencing our source code files and building our app, we need to go through the folders in the final build and check for the TypeScript definition files. If they’re not found, we want to know which one it’s missing. To that end, we’ll add some logs:
function checkType(tree) {
const route = tree.route.replace('src', 'dist')
const baseName = path.basename(path.resolve(route))
if (fs.existsSync(route + '/index.d.ts')) {
console.log(`✅ default typing ${route + '/index.d.ts'}`)
return
}
if (fs.existsSync(route.replace('.ts', '.d.ts'))) {
console.log(`✅ named typing ${route}`)
return
}
throw new Error(`🚫 ${route} not found!`)
}
And now we need another recursive function to go through the tree. It will either check that the TypeScript definition files are there, if it has no child directory, or it will call itself again on that directory:
function checkTree(tree) {
if (tree.children.length === 0) {
checkType(tree)
return
}
tree.children.forEach((subTree) => {
checkTree(subTree)
})
return
}
It’s time to build the tree and run all these functions:
const tree = buildTree('./src')
checkTree(tree)
Additionally, we can create an allowlist to check for specific files that we expect to be at the root of our build, like plain JS files, CSS, and even source maps:
const distFiles = [
'index.d.ts',
'index.js',
'index.modern.js',
'index.css',
'index.css.map',
'index.js.map',
'index.modern.js.map',
]
distFiles.forEach((f) => {
if (fs.existsSync('./dist/' + f)) {
console.log(`✅ root ./dist/${f}`)
} else {
throw new Error(`🚫 ./dist/${f} not found!`)
}
})
Putting it into action with GitHub Actions
Ensuring that your TypeScript definition files exist is a good check to put in your Travis jobs or GitHub Actions workflows. You can set up a GitHub Action that builds your app and immediately after runs this script. If you save this as an integrityCheck.js
file in a scripts
directory, you can add this check as a new entry in your package.json
in the scripts
key:
"integrityCheck": "node ./scripts/integrityCheck.js",
and then call this in your GitHub Actions or Travis like:
npm run build && npm run integrityCheck
Because we added some log output, if it fails, you’ll have the exact file that wasn’t found and will be able to diagnose and debug the issue leading to the missing TypeScript definition file.