Right, so you’ve got your unit tests passing. Your functions are returning the right values. But are they returning the right types? Are you sure? In a language like TypeScript, a function can pass all its runtime tests with flying colors and still be a type-level catastrophe waiting to happen. This is where type-level testing comes in. It’s like putting a second, even more pedantic security guard at the door of your codebase—one who only cares about the IDs and not the person.

We’re going to look at two tools that are essential for this: tsd, a dedicated type-testing library, and expect-type, which brings similar capabilities right into your existing Jest or Vitest setup. Think of it as the difference between a dedicated linting tool and your editor’s built-in linter. Both are vital.

The Core Idea: Asserting on Types, Not Values

A normal test says, “When I call add(2, 2), I expect the value 4 to be returned.” A type-level test says, “I expect the type of what add(2, 2) returns to be number.” More importantly, it can assert that two types are equivalent, or that one type is assignable to another.

This is incredibly powerful for testing complex generics, function overloads, or ensuring your carefully crafted utility types actually do what you think they do.

Getting Started with tsd

tsd is the OG in this space. It works by parsing special comment directives in your test files. You install it as a dev dependency (npm i -D tsd) and then run it alongside your other tests.

Its magic lies in these special comments:

  • expectType<T>(value): Asserts that the type of value is exactly T.
  • expectError<T>(value): Asserts that the following expression contains a type error. The code won’t actually run; tsd just wants to see TypeScript complain about it.

Let’s say you have a maybeGetUser function that might return undefined. You want to be absolutely certain your isUser type guard works correctly.

// maybeGetUser.test-d.ts
import { expectType } from 'tsd';
import { isUser, maybeGetUser } from './user';

const result = maybeGetUser(1);

if (isUser(result)) {
  // Inside this block, result should be User, not User | undefined
  expectType<User>(result);
} else {
  // And here, it should definitely be undefined
  expectType<undefined>(result);
}

You run this with npx tsd. It doesn’t execute your code; it just checks the types. If the types align, the test passes. If they don’t, it fails. It’s brutally effective.

The beauty of expectError is in testing your own code’s boundaries. If you’ve designed a function that should only accept a specific string literal, you can prove it:

// strictFunction.test-d.ts
import { expectError } from 'tsd';
import { onlyAcceptsHello } from './strict';

onlyAcceptsHello('hello'); // This is fine, no test needed.

// This should be a type error, and we're testing for that error.
expectError(onlyAcceptsHello('goodbye'));

Integrating with Vitest/Jest using expect-type

Now, tsd is great, but it’s a separate tool. What if you want your type tests to live right next to your runtime tests in your *.test.ts files? This is where expect-type (or Vitest’s built-in expectTypeOf) shines. It lets you write type assertions inside your normal test suites.

First, install it: npm i -D expect-type. Then, use it alongside your normal expect.

// user.test.ts
import { maybeGetUser, isUser } from './user';
import { expectTypeOf } from 'expect-type';

describe('User functions', () => {
  it('maybeGetUser returns User | undefined', () => {
    const result = maybeGetUser(1);
    // Runtime test
    expect(result).toBeDefined();

    // Type test: is the type exactly User | undefined?
    expectTypeOf(result).toEqualTypeOf<User | undefined>();
  });

  it('isUser narrows the type correctly', () => {
    const result = maybeGetUser(1);

    if (isUser(result)) {
      // Type is narrowed to User here
      expectTypeOf(result).toMatchTypeOf<User>(); // Checks for structural match
      expectTypeOf(result).not.toMatchTypeOf<undefined>();
    }
  });
});

The key methods here are toEqualTypeOf (exact type equality) and toMatchTypeOf (structural compatibility, a la “is assignable to”). This distinction is crucial.

Common Pitfalls and Sharp Edges

  1. Testing the Implementation, Not the Contract: It’s easy to fall into the trap of testing how you implemented a type instead of what it does. If you change a type from an interface to a type alias with the same shape, your toEqualTypeOf test will break, even though the contract is identical. Often, toMatchTypeOf is the better, less brittle choice.
  2. The Curse of any: any is the ultimate cheat code. expectTypeOf(value).toEqualTypeOf<any>() will always pass because everything is assignable to any. You usually want to avoid this. Instead, use expectTypeOf(value).not.toBeAny() and expectTypeOf(value).toBeUnknown() to ensure type safety.
  3. It’s Just TypeScript: Remember, these tools aren’t magic. They’re leveraging the TypeScript compiler itself. If your tsconfig.json is loose ("strict": false), your type tests might pass even when they shouldn’t, because the compiler is allowing sloppy things. These tools demand a strict environment to be meaningful. They are the strictest judge you have.
  4. Placement Matters with tsd: The expectError directive must be placed right before the line that you expect to error. It’s parsing your comments, not your code’s execution flow. It’s a bit janky, but it works.

Ultimately, type-level testing is what separates a good TypeScript codebase from a truly robust one. It catches entire classes of errors that runtime tests can’t even see. It’s the final, definitive proof that your types are telling the truth. And in a language where the types are the truth, that’s everything.