DEV Community

101arrowz
101arrowz

Posted on

Creating a modern JS library: TypeScript and Flow

Before we learn what you need to support TypeScript and Flow, let's think about why people use them in the first place. The main problem is that JavaScript is a dynamically, weakly typed language, but many programmers want static (and sometimes strong) typing.

Dynamic typing means that there are no types at compile-time. This means that you could accidentally add a function and a number, but you wouldn't know until runtime. Very few interpreted and JIT-compiled languages support static typing.

// There is no way to declare a type for a and b, even
// though a clearly needs to be a function
const myFunction = (a, b) => {
  return a(b);
}

// This function call does not throw a type error
// until it is executed, but a statically typed
// language would not compile.

// Think of a compile-time type error like a syntax
// error. Your code doesn't need to run for a type
// error to occur in statically typed languages.
const myResult = myFunction(
  { you: 'should not' },
 'be able to do this'
);
Enter fullscreen mode Exit fullscreen mode

In contrast, a language like C will never allow something like this:

#include <stdio.h>

// This throws a compile time warning (or error,
// depending on your configuration)
const char* example() {
  // Not a string literal, so the program does
  // not compile and you cannot run it
  return 20;
}

int main() {
  printf("%s", example());
  return 0;
}
Enter fullscreen mode Exit fullscreen mode

Weak typing means that JavaScript will not crash/throw an error when performing an illegal operation and will instead try to make that operation work. This kind of behavior is the origin of many WTFs by JS developers.

// This is perfectly valid JavaScript
const weirdAddition = [] + [];
console.log(weirdAddition); // ""

// That worked because the engine implicitly called
// [].toString() when it saw the addition operator.

// An empty array gives an empty string, hence the
// result is "" + "" = "".
Enter fullscreen mode Exit fullscreen mode

This behavior is the polar opposite of Python: any invalid operation will immediately cause an exception. Even adding a string and a number will fail and ask you to convert the number to a string first.

a = '9 + 10 = '
b = 9 + 10

# This fails: you must explicitly cast b to string
print(a + b)

# Only this works
print(a + str(b))
Enter fullscreen mode Exit fullscreen mode

Although JavaScript's virtually non-existent type system give programmers more flexibility, it's also the source of many bugs. Being both dynamically and weakly typed, at no point will you get an error if you make a mistake with types. Therefore, programmers wanted a solution to add types to JavaScript.

Enter TypeScript: an extension to JavaScript that adds syntax support for typings, a compiler, and incredible autocomplete support that was never previously possible in JavaScript.

// TypeScript accepts reasonable implicit conversions
const myFunction = (x: number) => 'hello ' + x;

// This will not compile, even with an explicit return type
// Adding arrays is not a reasonable use of dynamic typing
const myOtherFunction = (
  x: string[],
  y: string[]
): string => x + y;

// This will fail at compile time as well, since the first
// parameter of myFunction must be a number
myFunction('hello');
Enter fullscreen mode Exit fullscreen mode

I highly recommend using TypeScript in your library because it compiles to any version of JavaScript, even as early as ES3. You can support legacy and modern JS environments, support both JavaScript and TypeScript users, and prevent bugs within your code by using TypeScript. Whether or not you decide to use TS, supporting TS users can be confusing, so read on.

Supporting TypeScript from a TypeScript project

If your library is written in TypeScript, you can automatically generate both JavaScript code (to support all users) and TypeScript declaration files (which add TypeScript types to JavaScript code). You will almost never need to export TypeScript files in your package, unless all of your users will use TypeScript (i.e. for something like typegoose).

The main think you need to do is enable the declaration compiler option in tsconfig.json.

{
  "compilerOptions": {
    "outDir": "lib/",
    // This is the relevant option
    // The types you need will be exported to lib/
    "declaration": true
  }
}
Enter fullscreen mode Exit fullscreen mode

If you're not using the TypeScript compiler to build your code (using the noEmit option), you'll want to use emitDeclarationOnly as well.

{
  "compilerOptions": {
    "outDir": "lib/",
    "declaration": true,
    // Remove noEmit and replace it with this
    "emitDeclarationOnly": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, in package.json, use the "types" field to include your types.

{
  "main": "lib/index.js",
  "types": "lib/index.d.ts"
}
Enter fullscreen mode Exit fullscreen mode

Supporting TypeScript from a JavaScript project

Just as with a TypeScript project, you need to export both JavaScript files and TypeScript declaration files to make your code usable for both TypeScript and JavaScript users.

Creating and maintaining a declaration file by hand can be difficult, so you'll want to make sure to read the docs on declaration files. If you have trouble with the syntax, try looking at the typings for popular packages such as Express.

First, you'll need to figure out whether the files you export from your package use CommonJS or ES Modules. CommonJS looks like this:

// module.exports or exports indicate CommonJS
module.exports = {
  a: 1,
  b(c, op) {
    if (op == 'sq') return c ** 2;
    if (op == 'sqrt') return Math.sqrt(c);
    throw new TypeError('invalid operation')
  }
}

// For exporting one thing:
module.exports = 'hello';
Enter fullscreen mode Exit fullscreen mode

In contrast, ES Modules look like this:

// The export keyword indicates ESM
export const a = 1;
export function b(c, op) {
  if (op == 'sq') return c ** 2;
  if (op == 'sqrt') return Math.sqrt(c);
  throw new TypeError('invalid operation')
}

// export default for one thing
export default 'hello';
Enter fullscreen mode Exit fullscreen mode

If you export both (we'll get into how to do this in a future article), only make a declaration file using ESM because the CommonJS declarations can almost always be inferred by the TypeScript compiler from the ESM version.

If you're using CommonJS, use namespaces to encapsulate your package. Optimally, you'll also export types and interfaces that make TypeScript use more convenient.

// index.d.ts

// Everything in the namespace is exported

// If you want to use a type within the declaration
// file but not export it, declare it outside
declare namespace MyPackage {
  const a: number;
  // This type prevents TypeScript users from
  // using an invalid operation
  type MyOp = 'sq' | 'sqrt';
  function b(c: number, op: MyOp): number;
}

export = MyPackageName;

// For a single export:
declare const myPackage: string;
export = myPackage;
Enter fullscreen mode Exit fullscreen mode

Alternatively, if you're using ESM, you don't need (and shouldn't use) a namespace; export as you would in JavaScript.

export const a: number;
export type MyOp = 'sq' | 'sqrt';
export function b(c: number, op: MyOp): number;

// For a single export:
declare const myPackage: string;
export default myPackage;
Enter fullscreen mode Exit fullscreen mode

Now that you have a complete index.d.ts, you can do one of two things. You can either:

  • Add it to your own NPM package, as with the TypeScript version
  • Add it to the DefinitelyTyped repository to get an @types/your-package-name package automatically

I recommend adding the declaration to the NPM package because that reduces the amount of time and effort it takes to update your package, as well as giving you more flexibility with regards to the TypeScript features you can use and removing the need to add tests.

However, if you have many dependencies whose types you need to include (e.g. if you export a React component, you depend on the React typings), you need to add @types/dependency-name to your dependencies, not devDependencies, and that adds bloat for your end users that don't use TypeScript. In those cases, it's often better to publish to DefinitelyTyped.

What about Flow?

The process of supporting Flow users is extremely similar to that of TypeScript. Instead of adding the definition file to "types" in package.json, make a .js.flow file alongside every .js file that is being exported (for example, if you export lib/index.js, make sure to create lib/index.js.flow with the definitions). See the docs on how to create such a definition. If you want to support Flow yourself, don't publish to flow-typed; it's mainly meant for community members to create their own types for third-party packages and publish them there.

// @flow

// If this is an ES Module:
declare export function sayHello(to: string): void;

// Alternatively, if this is CommonJS:
declare module.exports: {
  sayHello(to: string): void;
}
Enter fullscreen mode Exit fullscreen mode

If you are writing your library with Flow, you can use build tooling to automate the process. Alternatively, use flowgen to only need to maintain a TypeScript definition file and automate the process of Flow support. In any case, Flow is pretty rare today; supporting just TypeScript will probably never be a problem.

Top comments (0)