TypeScript is becoming increasingly popular as more developers find it useful. Not only can you get strong typing support, but you can also use newer spec syntax. The former is one of the reasons we chose TypeScript in our Gimbal project, as typing support helps a dynamic team by showing errors early on and documenting what code should be. In this article, we will be discussing a generic TypeScript project that we will be publishing to the npm repository with an optional binary that can be executed in the command line.
Project Structure
The structure of any TypeScript project is key and can be easily configured. It’s common to place your TypeScript files in the src
directory and have them compiled into the lib
(can be configured to be dist
) directory. By default, your custom types, when abstracted out of the individual files where they are used, would go into the typings
directory and have the file extension of d.ts
. There is nothing that says the directories must be named this, it’s just a common convention that has been adopted as a best practice across the industry.
A sample structure would be:
/ lib/ index.js src/ index.ts typings/ index.d.ts package.json tsconfig.json
You don’t need to create the lib
directory. When you compile your TypeScript, this will be created for you in the above location.
Dependencies
Before we begin using TypeScript and compiling, we need to install some dependencies. You’ll only need three modules that should be installed as devDependencies
so we’ll use the --save-dev
option when installing them:
npm install --save-dev typescript ts-node @types/node
You’ll also need to tell npm which files to publish as we don’t want to publish the raw source, so that what is installed for users is as minimal as it can be. Notice that users can still access the full source code in the module’s repository. Npm will always publish some files like the README
and LICENSE
files (see the list here), so all you need to do is to add the lib
directory to the files array in your package.json:
"files": [ "lib" ]
I also tend to add two scripts to package.json:
"scripts": { "build": "tsc", "build:check": "tsc --noEmit", }
The build
script is what you would use when preparing to publish to npm. The build:check
can be used to run a one-off test to see if your build would succeed without creating the JavaScript files in the lib
directory.
tsconfig.json
TypeScript expects the tsconfig.json
to be in the root of your project as shown in the sample structure. This file tells TypeScript where your source files are, where to compile to, where your typings are, and what kind of module resolution in the compiled JavaScript should be used, among other things. A simple example would be:
{ "compilerOptions": { "target": "es5", "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, "resolveJsonModule": true, "lib": ["es6", "es2015"], "types": ["node"], "declaration": true, "outDir": "lib", "rootDir": "src", "strict": true, }, "exclude": [ "lib", "node_modules", "src/**/*.spec.ts" ], "include": [ "src" ] }
There are five options that are important and need to be pointed out:
outDir
– This is the name of the directory of the compiled JavaScript. The compiled structure of this directory will be the same as the structure of therootDir
.rootDir
– The directory where the TypeScript source is.types
– An array of typing names to always load. In this example, it will use the@types/node
module we installed before.exclude
– An array of glob paths to exclude from the TypeScript compilation.include
– An array of glob paths to include in the TypeScript compilation.
For more information on tsconfig.json
, please see the TypeScript documentation. For more information on the options that can go into the compilerOptions object, see this TypeScript documentation including default values.
Adding a bin
If your project needs a binary executable that can be executed from the command line, you’ll need to add the bin
directory to the exclude array in your tsconfig.json
and add the bin
config to your package.json
. The bin
directory will not be compiled with TypeScript so all files in there need to be in plain JavaScript.
An example of the bin
config within your package.json
is:
"bin": { "my-cli": "./bin/index.js" },
Here is an example of the bin/index.js
file:
#!/usr/bin/env node const minimist = require('minimist'); const argv = minimist(process.argv.slice(2)); require('../lib')(argv);
We are using the require
builtin function here since we are in a JavaScript file that has to work within Node.js. This binary will execute the compiled JavaScript; we execute it passing in the arguments from the command line parsed by minimist
. This allows your project to also be executed programmatically, not just from the command line. Minimist is just one of the command line parsers but is minimal. If you need something more advanced you may want to check out commander or yargs.
The last step is to add the bin
directory to the files array in package.json
telling npm to publish it as well:
"files": [ "bin", "lib" ]
Testing the bin
Now that you have your bin created, it’s time to test and debug using the bin. This is where the ts-node
module comes in handy as you’ll need to execute the code while compilingTypeScript on the fly. I normally set up three start scripts in package.json
, as follows:
"start": "node -r ts-node/register bin/index.js", "start:break": "node --inspect-brk -r ts-node/register bin/index.js", "start:debug": "node --inspect -r ts-node/register bin/index.js"
Each of these scripts do the same thing: they will compile your raw TypeScript on the fly using ts-node
. The difference is whether you want to debug as the source executes or not. The start
script will run your TypeScript without any debugging (other than console.log()
s of course). The start:break
and start:debug
scripts will open a debugging port that allows you to connect an external debugger, such as Google Chrome or an editor. The difference between these two is start:break
will not execute your TypeScript until you have attached an external debugger and resume execution, while start:debug
will continue to run your source but you can still attach a debugger until the process ends. You’ll likely want to use start:break
so you can attach the debugger and set any breakpoints unless you are working on an async operation, like a web server, that you want to get running.
You would execute one of these on the command line like this:
npm start // or to pass arguments: npm run start:break -- --foo bar
The second line passes all the options to the right of — to your node script since npm has its own options that could get used before your node script would get used. Your src/index.ts
file would look like:
interface Arguments { foo: string; } const mymodule = (args: Arguments): void => { const { foo } = args; // ... }; export default mymodule;
If you are using VSCode, you can use a launch config that looks very similar to the start script above and allows you to use VSCode’s built in debugging:
{ "type": "node", "request": "launch", "name": "Test Bin", "runtimeArgs": [ "-r", "ts-node/register" ], "args": [ "${workspaceFolder}/bin/index.js", "--foo", "bar" ], "skipFiles": [ "/**/*.js" ] }
You can also use npm link
along with the build:watch
script in order to use your bin globally as if you installed it from a repository. This uses the compiled code which is why the build:watch
script can be used to rebuild automatically.
Publishing to NPM
The first thing to notice is that, by default, npm attempts to publish as a private package. Unless you pay for and need this feature, you’ll have to set it to public. Add the following to your package.json
:
"publishConfig": { "access": "public" },
Now, when you execute npm publish
, it will use this config and set the access to public. You can use npm publish --access publish
if you do not wish to have this in your package.json
, just make sure you don’t forget it (or include it as a CI step). The world will not end if you forget, but the publish will fail and you may have a commit and tag that you’ll have to clean up.
The flow to publish is basically:
npm version patch npm run build npm publish
While you can do this all locally, I would always recommend setting up a CI/CD pipeline to automate this. Then, all you would be responsible for is executing the npm version patch
command, and once you push the tag up to the remote git repository via executing git push --follow-tags
, your CI/CD will do the building and publishing for you. An example CircleCI .circleci/config.yml
file looks like:
version: 2.1 jobs: publish: docker: - image: circleci/node:10 steps: - checkout - run: name: Setup .npmrc credentials command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc - run: npm ci - run: npm run build - run: npm publish workflows: version: 2 publish: jobs: - publish: filters: branches: ignore: /.*/ tags: only: /^v.*/
Conclusion
TypeScript brings a lot of protection with the strong typing you can use. Many of today’s editors also have support for TypeScript showing you in-line hints and errors. In a world where anyone can submit a pull request to fix a bug or add a new feature, TypeScript is a natural fit as it can help catch bugs and increase developer productivity.
Mitchell Simoens
Related Posts
-
Swift Modules for React Native
React Native is an Objective-C application framework that bridges JavaScript applications running in the JSCore…
-
Swift Modules for React Native
React Native is an Objective-C application framework that bridges JavaScript applications running in the JSCore…