Testing TypeScript Types: tsd, expect-type, and Type-Level Tests

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 tsd

Add 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 T

expectType 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 tsd

Output:

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-type

Writing 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 --typecheck

In .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:types

Vitest typecheck:

- name: Unit and type tests
  run: npx vitest run --typecheck

Both 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.

Read more