Testing TypeScript Types: tsd, expect-type, and Type-Level Tests
TypeScript types are part of your API surface — a change to a generic utility or function signature can break callers without touching any runtime behavior. Type-level tests catch these regressions. Tools like tsd, expect-type, and @vitest/expect-type let you assert that expressions have specific types, that certain type combinations are forbidden, and that generics infer correctly.
Key Takeaways
Types are an API. Changing Promise<User> to Promise<User | null> is a breaking change. Type tests catch it before users do.
tsd runs type checks as a standalone command. Place .test-d.ts files next to your source. expectType<T>(value) and expectError(value) are the two main assertions.
expect-type integrates with Jest and Vitest. expectTypeOf(value).toEqualTypeOf<T>() is the core assertion. It also has .toBeString(), .toBeNumber(), .parameters, .returns helpers.
Type tests run zero runtime code. They're erased at compile time. A failing type test is a TypeScript compile error, not a Jest failure — unless you're using a runtime integration.
Test generic inference explicitly. If a function infers a type parameter, write a test that checks what gets inferred. Refactoring generics often changes inference in unexpected ways.
Why Test Types?
Runtime tests verify behavior. Type tests verify contracts.
If you publish a TypeScript library, the types you export are part of your public API. A seemingly innocent internal refactoring can change how generics infer, widen a return type, or make previously required parameters optional. Callers notice when they update your package and their code stops compiling.
Type tests catch this class of regression before release.
Even for internal code, type tests are useful for:
- Documenting expected type inference behavior
- Preventing accidental widening of return types
- Ensuring conditional types resolve correctly
- Catching TypeScript version upgrades that change behavior
Tool Options
Three main tools exist for TypeScript type testing:
| Tool | Integration | Assertion style |
|---|---|---|
| tsd | Standalone CLI | expectType, expectError |
| expect-type | Jest / Vitest | expectTypeOf().toEqualTypeOf<T>() |
| @vitest/expect-type | Vitest only | Same as expect-type, built-in |
tsd
tsd is the oldest and most battle-tested. It runs as a standalone check against .test-d.ts files.
Installation
npm install --save-dev tsdAdd to package.json:
{
"scripts": {
"test:types": "tsd"
},
"tsd": {
"directory": "src"
}
}Writing tsd Tests
Type test files use the .test-d.ts extension:
// src/utils.test-d.ts
import { expectType, expectError, expectAssignable } from 'tsd';
import { pluck, merge, identity } from './utils';
// Test that pluck returns the correct type
declare const users: Array<{ id: number; name: string; age: number }>;
expectType<number[]>(pluck(users, 'id'));
expectType<string[]>(pluck(users, 'name'));
// Test that pluck errors on non-existent keys
expectError(pluck(users, 'email')); // 'email' does not exist on type
// Test that merge combines two object types
declare const a: { x: number };
declare const b: { y: string };
expectType<{ x: number; y: string }>(merge(a, b));
// Test that identity preserves the type
expectType<number>(identity(42));
expectType<string>(identity('hello'));Key tsd Assertions
expectType<T>(value) // value must be exactly T
expectError(expression) // expression must produce a type error
expectAssignable<T>(value) // value must be assignable to T (less strict)
expectNotAssignable<T>(v) // value must NOT be assignable to T
expectNotType<T>(value) // value must not be exactly TexpectType is strict — expectType<string | number>(str) fails if str is just string. Use expectAssignable when you want to check subtype compatibility.
Running tsd
npm run test:types
<span class="hljs-comment"># or directly:
npx tsdOutput:
src/utils.test-d.ts:12:1 - Argument of type '"email"' is not assignable to...
1 error [type]expect-type
expect-type integrates with Jest and Vitest, so type assertions live alongside runtime assertions in the same test file.
Installation
npm install --save-dev expect-typeWriting expect-type Tests
// src/utils.test.ts
import { expectTypeOf } from 'expect-type';
import { pluck, merge, identity, parseDate } from './utils';
describe('type signatures', () => {
it('pluck returns array of the picked key type', () => {
type Users = Array<{ id: number; name: string }>;
declare const users: Users;
expectTypeOf(pluck(users, 'id')).toEqualTypeOf<number[]>();
expectTypeOf(pluck(users, 'name')).toEqualTypeOf<string[]>();
});
it('identity preserves the exact type', () => {
expectTypeOf(identity(42)).toBeNumber();
expectTypeOf(identity('hello')).toBeString();
expectTypeOf(identity(true)).toBeBoolean();
});
it('parseDate returns Date or null, never undefined', () => {
expectTypeOf(parseDate('2026-01-01')).toEqualTypeOf<Date | null>();
expectTypeOf(parseDate('2026-01-01')).not.toBeUndefined();
});
});Checking Function Signatures
import type { UserService } from './user-service';
it('getUser accepts string and returns Promise<User>', () => {
expectTypeOf<UserService['getUser']>()
.parameters
.toEqualTypeOf<[string]>();
expectTypeOf<UserService['getUser']>()
.returns
.toEqualTypeOf<Promise<User>>();
});Testing Generic Inference
function wrap<T>(value: T): { value: T } {
return { value };
}
it('wrap infers the generic parameter correctly', () => {
expectTypeOf(wrap(42)).toEqualTypeOf<{ value: number }>();
expectTypeOf(wrap('hello')).toEqualTypeOf<{ value: string }>();
expectTypeOf(wrap([1, 2, 3])).toEqualTypeOf<{ value: number[] }>();
});Testing Conditional Types
type IsArray<T> = T extends any[] ? true : false;
it('IsArray resolves correctly', () => {
expectTypeOf<IsArray<string[]>>().toEqualTypeOf<true>();
expectTypeOf<IsArray<string>>().toEqualTypeOf<false>();
expectTypeOf<IsArray<number[]>>().toEqualTypeOf<true>();
});Vitest Built-in Type Testing
Vitest 0.34+ has built-in type testing. Enable it in vite.config.ts:
export default defineConfig({
test: {
typecheck: {
tsconfig: './tsconfig.test.json',
},
},
});Run type tests:
npx vitest typecheck
# or combined:
npx vitest --typecheckIn .test-ts files, use the same expectTypeOf from expect-type:
import { expectTypeOf } from 'vitest';
test('Config is correctly typed', () => {
type Config = { port: number; host: string; debug?: boolean };
expectTypeOf<Config['port']>().toBeNumber();
expectTypeOf<Config['debug']>().toEqualTypeOf<boolean | undefined>();
});Practical Example: Testing a Utility Library
Say you have a pick utility:
// src/pick.ts
export function pick<T extends object, K extends keyof T>(
obj: T,
keys: K[]
): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => {
result[key] = obj[key];
});
return result;
}Type tests:
// src/pick.test-d.ts (tsd)
import { expectType, expectError } from 'tsd';
import { pick } from './pick';
const user = { id: 1, name: 'Alice', email: 'alice@example.com', age: 30 };
// Basic pick
expectType<{ id: number; name: string }>(pick(user, ['id', 'name']));
// Single key
expectType<{ email: string }>(pick(user, ['email']));
// Unknown key should error
expectError(pick(user, ['unknown']));
// Empty array returns empty object
expectType<{}>(pick(user, []));When you refactor pick and accidentally change the return type, these tests fail immediately.
Adding Type Tests to CI
tsd in GitHub Actions:
- name: Type tests
run: npm run test:typesVitest typecheck:
- name: Unit and type tests
run: npx vitest run --typecheckBoth commands exit with non-zero on failure. Run them as separate CI steps so failures are clearly labeled.
What Type Tests Don't Cover
Type tests verify compile-time contracts. They don't verify:
- That your functions do what the types claim
- Runtime behavior with real data
- Edge cases that TypeScript's type system can't express
A function typed as (id: string) => Promise<User> could throw in practice. Type tests won't catch that — runtime tests and production monitoring will.
HelpMeTest tests your live application continuously. Start with the free tier: 10 tests, 5-minute monitoring intervals, no infrastructure needed.