What is type-testing with TypeScript? Is it an anti-pattern? Is it worth it?
Recently I posted about this commenting on Type-Testing, and it went so well! I was really happy about all the comments and questions people left there.
Because of the reach and because many people had some really relevant questions, I decided to write a longer article about the topic, explaining more deeply how everything works and what this type-testing thing is.
Type-Testing?#
That’s right, there’s another way to do tests that isn’t a unit test or an integration test. These are type tests.
The idea of type testing is being able to test whether the typing you’ve given to a certain function or file is correct. Think of it as a test, but for your types instead of your code.
Ideally a type test won’t even run. All the code will just be compiled by TypeScript and that result will be shown to you to indicate whether your typing is correct or not. This ensures that whoever uses the application has a guarantee that the types match what’s being provided.
How to test types#
There are several ways to do this. The simplest is basically to call the typing of the application you want to test, and make heavy use of directives like // @ts-expect-error that will act as a negative test, ensuring that your type is also providing an error when expected.
Let me talk about this PR again, but I recently added typing for a library that didn’t have native typing through .d.ts files in DefinitelyTyped. In the test files, we have to import our types and test them as if we were calling the application:
import keychain = require('keychain');
/** * setPassword */
// @ts-expect-error// Errors when doesn't have the required propertieskeychain.setPassword({ account: 'some-account' }, err => { if (err) { err; // $ExpectType KeychainError }});Another way, a bit more complicated, is to create your own utility types for testing. Imagine we’re creating a small framework that will help you test types:
type Expect<T extends true> = Ttype Equal<X, Y> = X extends Y ? (Y extends X ? true : false) : false
type test = Expect<Equal<typeof 1, number>>
// @ts-expect-errortype test_error = Expect<Equal<typeof 1, string>>Testing libraries#
Another way (much easier) is to use testing libraries, like vitest, which have type assertions. So we can do something like this:
import { assertType, expectTypeOf } from 'vitest'import { suaLib } from 'lib'
test('Tipos certos', () => { expectTypeOf(suaLib).toBeFunction() expectTypeOf(suaLib).parameter(0).toMatchTypeOf<{ x: 1 }>()})But vitest isn’t the only one that does this. Another very interesting library that’s made specifically for this deserves its own section.
TSD#
TSD is a type testing library focused specifically on testing declaration files .d.ts (we talked about them in #TypeScriptWeek). The big idea of this package is that you can use it as if it were TypeScript’s native compiler, but with one advantage: your code won’t be executed at all.
It looks for files with the .test-d.ts extension. These files aren’t executed and certainly not compiled in the native way. What will happen is that TSD will look for assertions like expectError or expectType and analyze their results against the types you wrote in your original file.
Additionally, it will look in the same folder (or in a specified path) for the corresponding
.d.tsfile so it can test.
An example can be the very type file I wrote for Keychain:
// Type definitions for keychain 1.4// Project: https://github.com/drudge/node-keychain// Definitions by: Lucas Santos <https://github.com/khaosdoctor>// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
declare namespace keychainTypes { interface KeyChainBaseOptions { account: string; service: string; password: string; type?: 'generic' | 'internet'; }
type KeychainErrorCodes = | 'UnsupportedPlatform' | 'NoAccountProvided' | 'NoServiceProvided' | 'NoPasswordProvided' | 'ServiceFailure' | 'PasswordNotFound';
type KeychainErrorType = `${KeychainErrorCodes}Error`;
class KeychainError extends Error { code: KeychainErrorCodes; type: KeychainErrorType; }}
declare function getPassword( options: Pick<keychainTypes.KeyChainBaseOptions, 'account' | 'service'>, callback: (err: keychainTypes.KeychainError, password: string) => void,): void;
declare function setPassword( options: keychainTypes.KeyChainBaseOptions, callback: (err: keychainTypes.KeychainError) => void,): void;
declare function deletePassword( options: Pick<keychainTypes.KeyChainBaseOptions, 'account' | 'service'>, callback: (err: keychainTypes.KeychainError) => void,): void;
declare const keychain: typeof keychainTypes & { getPassword: typeof getPassword; setPassword: typeof setPassword; deletePassword: typeof deletePassword;};
export = keychain;We can put this file in a folder named index.d.ts, then we create a file index.test-d.ts with the following content:
import { expectType } from 'tsd'import keychain from '.'
expectType<string>(keychain.getPassword({ account: 'account', service: 'service',}, (err, password) => { }))If we run npx tsd at the root, we’ll get an error output, because we’re not expecting a string. If we change the code to:
import { expectType } from 'tsd'import keychain from '.'
expectType<void>(keychain.getPassword({ account: 'account', service: 'service',}, (err, password) => { }))We’ll get the direct output of our types.
Test types? But that’s what types are for#
One of the main questions and comments (even complaints) on the post was about whether or not we need to test types. I think what I heard most, both in this post and in my talks about this topic, is the following phrase:
Why would I test my types if my types already serve to test whether I’m sending everything right?
And that’s completely correct! We don’t always need to test our types. In fact, this kind of test isn’t very useful when we’re building commercial applications or even APIs.
The main use of type tests is when we have external libraries that will be used by other people, or when we’re manually adding a declaration file (the famous .d.ts files) to a function or library that doesn’t have native types.
An example of this is exactly the PR I opened on DefinitelyTyped, to type the Keychain library, which didn’t have native typing. In that PR we have this file. It’s exactly a type test because, for this kind of project (which is essentially a repository of external types), it makes total sense that we test our types.
The same goes, for example, if you’re developing a library that will be used by other people and you want to ensure that your changes will continue to maintain the use of the library the way you envisioned it, especially when you don’t have unit tests.
Type tests vs other tests#
Some people told me it would make sense to completely replace (in cases like this library one) unit tests and use only type tests, because they’re practically instant and can test the library’s usage.
I honestly don’t recommend it. Unit tests are more focused on testing the responses and direct usage of your application or library. Type tests don’t focus on the result, but rather on the usage, so you need to test that incorrect usages are also showing errors, as I do here:
// @ts-expect-error// Another error when missing optionskeychain.setPassword({ account: 'some-account', password: 'some-pass' }, err => { if (err) { err; // $ExpectType KeychainError }});Conclusion, is it worth it?#
You’re probably expecting a conclusion, so here goes the famous word that any senior will say: it depends.
Type tests are another kind of test, meaning other files, other logic, another way of thinking that need to be written by you (or by ChatGPT). In other words, it takes time.
However, having type tests adds an extra layer of protection. They will ensure in a quite lightweight way that your types are correct and that they work as expected, both for you and for everyone else who uses them.
So, like anything else, we have a trade-off: more development time for greater security in the application.
I personally don’t think it’s worth writing type tests for any application by default. Mainly because it will take more time and dedication. I believe type tests will show themselves to be necessary when you have a problem with untested types. Remember: don’t optimize prematurely.