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.
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:
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:
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:
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:
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:
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:
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:
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:
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.
| Question | Use a runtime test | Use a type test |
|---|---|---|
| Does the function return the correct value? | Yes | No |
| Does the function throw when it should? | Yes | No |
| Does the return type change when the input type changes? | No | Yes |
| Does a generic infer the right type for each call? | No | Yes |
| Does a public function signature stay stable across refactors? | Partially | Yes |
Running Type Tests
Type tests run with the --typecheck flag alongside your regular test suite:
npx vitest run --typecheckYou 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
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.
Frequently Asked Questions
Do type tests run at runtime?
What is the difference between expectTypeOf and assertType?
Can I use type tests with Jest instead of Vitest?
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.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.