Write Type Safe Unit Tests

Use Vitest's expectTypeOf and assertType to test TypeScript types at compile time. Catch type-level regressions before they reach production.

6 min read

Type safe unit tests verify the types your functions accept and return, not just the values they produce. Unlike runtime tests, these tests run at compile time through the TypeScript compiler. They never execute as JavaScript.

Vitest bundles both kinds of testing into one tool. The compiler checks your type assertions and Vitest reports mismatches as test failures.

Two APIs for Type Assertions

Vitest provides two APIs for type assertions, and you will likely use both.

expectTypeOf is a fluent API with chained matchers. It is best for inspecting a single type in detail: its parameters, return type, properties, and whether it extends another type.

assertType is simpler. You pass a value and a type argument, and the compiler checks that the value satisfies the type. It pairs naturally with @ts-expect-error for negative tests.

Here is how they look side by side:

typescripttypescript
import { assertType, expectTypeOf, test } from "vitest";
 
function greet(name: string): string {
  return `Hello, ${name}!`;
}
 
test("greet returns a string", () => {
  const result = greet("Ada");
  expectTypeOf(result).toBeString();
  assertType<string>(result);
});

Both assertions verify the same thing: that greet returns a string. Pick whichever reads more naturally for the assertion you are making. expectTypeOf is more expressive for complex checks involving parameters or return types. assertType is more concise for simple type checks and pairs well with negative tests that use @ts-expect-error comments.

Testing Function Signatures

The real power of expectTypeOf is inspecting function types without calling them. You check parameter types by position:

typescripttypescript
import { expectTypeOf, test } from "vitest";
 
function fetchData(url: string, timeout: number): Promise<Response> {
  return fetch(url, { signal: AbortSignal.timeout(timeout) });
}
 
test("fetchData accepts the right parameters", () => {
  expectTypeOf(fetchData).toBeFunction();
  expectTypeOf(fetchData).parameter(0).toBeString();
  expectTypeOf(fetchData).parameter(1).toBeNumber();
});

The parameter checks above only cover the input side of the function. Use the returns matcher to check the output side, the type that fetchData resolves to when its promise settles:

typescripttypescript
test("fetchData returns a Promise of Response", () => {
  expectTypeOf(fetchData).returns.toEqualTypeOf<Promise<Response>>();
});

You did not call fetchData. You inspected its type shape. The parameter matcher checks argument types by position, and the returns matcher checks the return type. If someone changes the function signature later, this test fails at compile time.

Testing Generic Functions

Generic functions are harder to test at runtime because the type parameter disappears. Type tests let you verify that the generic behaves correctly with different type arguments. Here is firstElement tested with a number array and a string array:

typescripttypescript
import { expectTypeOf, test } from "vitest";
 
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
test("firstElement returns the element type", () => {
  const nums = [1, 2, 3];
  const strings = ["a", "b", "c"];
 
  expectTypeOf(firstElement(nums)).toBeNumber();
  expectTypeOf(firstElement(strings)).toBeString();
});

Also verify the edge case where an empty array returns undefined instead of an element. This confirms that TypeScript correctly narrows the return type to include undefined when the array might have no elements:

typescripttypescript
test("firstElement on empty array returns undefined", () => {
  const empty: number[] = [];
  expectTypeOf(firstElement(empty)).toBeUndefined();
});

Each call to firstElement produces a different return type based on the input array. The type tests verify that TypeScript infers the correct type in each case without you having to manually annotate anything.

Negative Type Tests

Sometimes the most important assertion is that code does not compile. A function should reject certain inputs, and you want to be sure that restriction is preserved during refactoring:

typescripttypescript
import { assertType, test } from "vitest";
 
function divide(a: number, b: number): number {
  if (b === 0) throw new Error("Cannot divide by zero");
  return a / b;
}
 
test("divide rejects string arguments", () => {
  // @ts-expect-error string is not assignable to number
  assertType(divide("10", 2));
});

The @ts-expect-error comment tells TypeScript that the next line should produce a type error. If the line compiles without error, TypeScript reports the comment as unused, and Vitest fails the test. This is how you lock in the rejection of invalid inputs.

Testing Discriminated Unions

Discriminated unions benefit heavily from type testing. You want to verify each variant has the correct shape:

typescripttypescript
import { expectTypeOf, test } from "vitest";
 
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string };
 
test("success state has a string array data property", () => {
  const success: SuccessState = { status: "success", data: ["a", "b"] };
  expectTypeOf(success.data).toBeArray();
  expectTypeOf(success.data).items.toBeString();
});

The items chain on an array type lets you inspect the element type without extracting it manually. This is cleaner than writing a separate type alias just for the assertion.

Testing Custom Utility Types

If your project defines custom utility types, type tests are the only way to verify they work correctly:

typescripttypescript
import { expectTypeOf, test } from "vitest";
 
type Nullable<T> = { [K in keyof T]: T[K] | null };
 
interface User {
  id: number;
  name: string;
}
 
test("Nullable makes every field nullable", () => {
  type NullableUser = Nullable<User>;
  expectTypeOf<NullableUser>().toExtend<{ id: number | null }>();
  expectTypeOf<NullableUser>().toExtend<{ name: string | null }>();
});

There is no runtime value to test here. The utility type only exists at compile time. expectTypeOf with an explicit type argument lets you assert on the type directly without needing a concrete value.

When to Use Type Tests vs Runtime Tests

Not every function needs a type test. Use type tests when the type signature is part of the public contract and changing it would break consumers downstream. Utility types, generic functions, and discriminated union variants are all strong candidates.

Runtime tests, on the other hand, verify behavior: does the function return the right value for a given input? Does it throw when it should? These questions need actual execution, and type tests cannot answer them.

A good rule: if the function is exported and other modules depend on its shape, write both a runtime test and a type test. The runtime test confirms behavior. The type test confirms the contract. Together they give you full confidence when refactoring.

QuestionUse a runtime testUse a type test
Does the function return the correct value?YesNo
Does the function throw when it should?YesNo
Does the return type change when the input type changes?NoYes
Does a generic infer the right type for each call?NoYes
Does a public function signature stay stable across refactors?PartiallyYes

Running Type Tests

Type tests run with the --typecheck flag alongside your regular test suite:

bashbash
npx vitest run --typecheck

You can also dedicate specific files to type-only tests by using the .test-d.ts extension. Vitest treats these files as type-check only by default.

For the complete Vitest setup including configuration, watch mode, and matchers, see Test TypeScript Code with Vitest. For picking a runner to execute your TypeScript scripts outside of tests, see tsx vs ts-node for Running TypeScript.

Rune AI

Rune AI

Key Insights

  • Use vitest --typecheck to run compile-time type assertions alongside runtime tests.
  • expectTypeOf provides a fluent API for asserting parameter types, return types, and properties.
  • assertType is simpler and pairs well with @ts-expect-error for negative type tests.
  • Test generic functions by calling them with specific type arguments and verifying the result type.
  • Type tests catch regressions when you change a function signature or type definition.
RunePowered by Rune AI

Frequently Asked Questions

Do type tests run at runtime?

No. Type tests are statically analyzed by the TypeScript compiler. They are never executed as JavaScript. Vitest only reports whether the types satisfy the assertions at compile time.

What is the difference between expectTypeOf and assertType?

expectTypeOf gives you a fluent API with matchers like toBeString() and toEqualTypeOf(). assertType is simpler: you pass a value and a type argument, and the compiler checks that the value matches the type.

Can I use type tests with Jest instead of Vitest?

Jest does not have built-in type testing. You would need to run tsc separately or use a third-party library like expect-type. Vitest's typecheck mode integrates both into one command.

Conclusion

Runtime tests verify behavior. Type tests verify that your functions accept and return the types you expect. Together they give you confidence that both the logic and the types are correct. Add --typecheck to your test script and write type assertions for every public function signature.