Home Separate Type Definitions? Just Don't
Post
Cancel

Separate Type Definitions? Just Don't

TypeScript has been a god-send. I’d prefer not working in JavaScript at all, but often blowing everything up and rewriting it in rust is not an option. So we have TypeScript.

First things first, TypeScript is a godsend. It has a powerful type system, with some really nice handling of falsy values. It’s a great tool for a not great situation.

For legacy projects separating type definitions from implementation can be an attractive option. We get to export types without the need to rewrite the source! However, this approach but it can lead to a number of problems. In this post, I’ll outline some of the reasons why you should avoid separating type definitions in your codebase.

The Problem with Separate Type Definitions

Maintenance Overhead

Separating type definitions is that it introduces an extra layer of maintenance overhead. There are now two files to maintain for every module in your codebase. That is two opportunities to make a mistake. Type definitions must be kept in sync with the implementation.

This can be particularly challenging when you have multiple developers working on the same codebase, as it’s easy for type definitions to fall out of date or for changes to be made to the implementation without updating the corresponding type definitions.

This additional overhead points to a manual step in which changes in the actual source (JavaScript) have to be translated into the stated interface (TypeScript). However, there is no enforcement mechanism at compile time, so it’s very easy to end up with mismatches.

API Mismatches

A more serious problem with separating type definitions is that it can lead to API mismatches between the actual code and the type definitions. This can happen when changes are made to the implementation but the corresponding type definitions are not updated to reflect those changes, or are not updated correctly.

For example, in the Sequelize version 6.6.5, there was a hook in the source code that was not included in the type definition file. This meant that any code that relied on the type definition would not be able to use that hook without disabling type checking for the entire configuration object.

It wasn’t until version 6.13.0, released 10 months later, that the hook was added to the type definition file.

This is a relatively minor issue, but it’s possible for more critical issues to sneak in. Is this type actually non-null? Does this property actually exist on this class? If the types are separate, there is no way to be certain until runtime… not great!

3. Increased Testing Burden

The best way to address this Type Checking gap is with an exhaustive test suite. For a JavaScript library, you’d probably want to perform some null checks and some cover any type checking logic in the source file.

However, with separate type definitions, the situation is much worse. The type definitions can be completely incorrect. The exported values could be in different modules. Classes could have completely different sets of properties and functions in the source and in the type definition. An exhaustive test suite would have to cover all of these cases!

That is quite a burden! It’s worth asking, if we are writing tests to enforce types, what is the point of TypeScript?

Alternative Approach: Convert to TypeScript

To avoid the problems I outlined above I recommend the following approach for legacy projects:

1. Immediately adopt the TypeScript compiler.

JavaScript source files work perfectly fine with the TypeScript compiler and every worthwhile build system has a ts-* plugin.

2. Enable noImplicitAny and strictNullChecks

These options activate the most useful checks that TypeScript provides: What is this thing? Can I count on it being there?

The latter is somewhat less useful since the introduction of optional chaining, but constant null checks can really clutter up code with unnecessary runtime logic. You can read more about the value of strictNullChecks here.

These options are especially useful when migrating from JavaScript source to TypeScript as without formal checks, it is inevitable that type mismatches and problems with null will creep in.

Without these options, a lot of the value of TypeScript is left on the table.

3. Convert .js to files .ts one by one.

Converting a codebase from JavaScript to TypeScript one file reduces the risks inherent with wholesale changes:

  1. Change the file suffix from .js to .ts.
  2. Define types as you go. This means adding type annotations to variables, parameters, and function return types. If you’re not sure what type to use, start with any and come back to it later.
  3. Use @ts-ignore if you encounter a strict null check error that you can’t resolve right away. This will allow you to keep working on the file without TypeScript complaining about the null check.

4. Remove uses of any and @ts-ignore

Lastly you’ll want to remove the uses of any and @ts-ignore:

  1. Use typescript-eslint to lint your TypeScript code. It’s a nice extension to the built in TS rules.
  2. Enable no-explicit-any .
  3. Enable ban-ts-comment . This will prevent you from using @ts-ignore in your code.

You can always disable or remove these rules temporarily or inline until you’ve cleaned out your entire code base.

Conclusion

The conversion process can be tedious, but it avoids the problems associated with separate type definitions and big bang updates that risk unknown and untested updates. Unless the maintainers of the project are unable or unwilling to convert the project, separate type definitions should be avoided.

-->