Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 081 / 100

Test your types: a practical JavaScript & TypeScript guide

Type-level tests catch API regressions.

Expona Admin7 min read

Test your types

When we talk about testing in JavaScript or TypeScript, most developers reach for unit tests, integration tests, and end-to-end tests. But there's a critical layer that often gets overlooked: type-level testing. Type-level tests catch API regressions before they reach your users, especially in libraries and shared packages where breaking changes can silently ripple through dozens of consuming applications. Unlike runtime tests that verify behavior, type tests verify that your type signatures remain stable and that the inference patterns your consumers depend on don't accidentally change.

Why type-level testing matters

I've seen production incidents where a seemingly innocent refactor changed what a function's return type inferred to be. A library maintainer simplified internal logic, the function still worked at runtime, but the inferred type shifted from Promise<User> to Promise<User | undefined>. Consumers' code that relied on the stricter inference broke their type checks, forcing emergency patches downstream.

Type-level tests are your safety net. They document the exact types users depend on and fail loudly during your CI pipeline if those contracts change. For platform teams, shared packages, or any library with multiple consumers, this becomes essential infrastructure.

expectTypeOf in Vitest

Vitest provides expectTypeOf, a powerful assertion helper specifically for type-level testing. It lets you write tests that run at compile time and verify that expressions have the exact types you expect.

typescript
import { expectTypeOf } from 'vitest';
import { fetchUser } from './user.api';

describe('User API types', () => {
  it('should infer Promise<User> from fetchUser', () => {
    const user = fetchUser(123);
    expectTypeOf(user).toMatchTypeOf<Promise<{ id: number; name: string }>>();
  });
});

The test doesn't execute at runtime. Instead, TypeScript compiles it and validates that the type of user matches your expectation. If someone changes fetchUser to return Promise<User | null>, that test will fail during type-checking, and your CI catches it before merge.

I use expectTypeOf constantly in platform libraries. It's lightweight to write, requires no runtime overhead, and provides immediate feedback. The syntax is fluent and readable, making it easy for junior developers to understand what contract is being protected.

Assert inferred return types

One of the most common regression patterns is when a function's inferred return type changes unexpectedly. This happens often with generic functions, higher-order functions, or APIs that use type inference to drive the developer experience.

Consider a utilities library that provides a pipe function:

typescript
function pipe<T>(value: T): PipeBuilder<T> {
  return new PipeBuilder(value);
}

class PipeBuilder<T> {
  constructor(private value: T) {}
  
  map<U>(fn: (val: T) => U): PipeBuilder<U> {
    return new PipeBuilder(fn(this.value));
  }
  
  value(): T {
    return this.value;
  }
}

Now, let's test that the inferred types flow correctly through chained operations:

typescript
describe('pipe inference', () => {
  it('should infer correct type after map operations', () => {
    const result = pipe(5)
      .map(n => n * 2)
      .map(n => n.toString())
      .value();
    
    expectTypeOf(result).toMatchTypeOf<string>();
  });

  it('should preserve generic type in builder', () => {
    const builder = pipe({ id: 1, name: 'Alice' });
    expectTypeOf(builder).toMatchTypeOf<PipeBuilder<{ id: number; name: string }>>();
  });
});

These tests ensure that as you refactor the PipeBuilder class, the type flow remains correct. A subtle bug like accidentally widening the generic constraint from <U> to <U extends any> would be caught immediately.

I've found this especially valuable for data transformation libraries and client SDKs where type inference drives the entire developer ergonomics. If your inference breaks, your users' IDE autocomplete breaks, and they abandon your library.

Check that invalid calls fail

Type tests also let you verify the negative case: that invalid calls are rejected by TypeScript. This is just as important as verifying what works.

typescript
describe('type safety boundaries', () => {
  it('should reject calling fetchUser with string instead of number', () => {
    // This line should not type-check
    expectTypeOf(() => fetchUser('abc')).toBeNever();
  });

  it('should enforce required parameters', () => {
    // @ts-expect-error - missing required id parameter
    fetchUser();
  });
});

Wait, let me clarify. The second approach using @ts-expect-error is actually how you'd write it in practice. You use the TypeScript directive to tell the type-checker that you expect this line to fail. If someone removes the parameter validation logic and suddenly the function accepts no arguments, that line will stop producing an error, and the test fails.

A more robust pattern using expectTypeOf:

typescript
describe('strict parameter validation', () => {
  it('should reject non-numeric IDs', () => {
    const badCall = (): any => fetchUser('abc' as any);
    // Using toBeNever() or checking error scenarios
    expectTypeOf<typeof fetchUser>().parameters.toMatchTypeOf<[id: number]>();
  });
});

For a CRM or trading platform, this is critical. If your customer API suddenly accepts both strings and numbers for a customer ID, and your frontend assumes numeric-only, you've got a silent bug waiting to happen. Type tests catch this at build time.

Useful for libraries and shared packages

Type-level testing shines most in shared infrastructure. When you maintain a package used by 10+ teams, every type change is a contract change. A shared utilities package, a client SDK, or a design system component library all need type tests.

At our platform, we have a shared @internal/api-client package that provides typed wrappers around our REST endpoints. We test the inferred response types heavily:

typescript
import { expectTypeOf } from 'vitest';
import { apiClient } from '@internal/api-client';

describe('API client response types', () => {
  it('getUser should infer User type', async () => {
    const response = await apiClient.getUser(123);
    expectTypeOf(response).toMatchTypeOf<User>();
    expectTypeOf(response.id).toMatchTypeOf<number>();
  });

  it('listUsers should infer array type', async () => {
    const response = await apiClient.listUsers({ limit: 10 });
    expectTypeOf(response).toMatchTypeOf<User[]>();
  });

  it('createUser should enforce required fields', () => {
    expectTypeOf<typeof apiClient.createUser>().parameters.toMatchTypeOf<
      [data: { email: string; name: string }]
    >();
  });
});

Any breaking change to response schemas or required parameters fails our type tests before CI completes. We also document what consumers can depend on: if it's tested, it's stable. If you want to change it, you update the test first, and maintainers review the change explicitly.

For libraries distributed on npm, this becomes your public API contract. You're telling every consumer: these types won't change unless we bump the major version. Semantic versioning gains real teeth when backed by type tests.

Common mistakes

Over-testing implementation details: Don't test every internal helper function's type. Focus on the public API surface and the types your consumers directly use. Testing private utility types creates brittle tests that fail on harmless refactors.

Using any too liberally: Type tests with any don't actually test anything. A function returning any passes type checks for any usage, defeating the purpose. Be explicit about expected types even when it's verbose.

Forgetting union types and intersection types: If your function legitimately returns User | null, test for that union. Don't write a test that expects only User and ignore the null case. Your test should reflect the actual contract.

Not testing error cases: Test that error handlers, rejected promises, and throw statements have the correct types. A promise that rejects with Error should be tested to confirm the rejection type, not just the success path.

Running type tests in isolation from integration tests: Type safety and runtime behavior need to align. A function might type-check perfectly but throw at runtime due to incomplete error handling. Run both kinds of tests together in your CI pipeline.

Key takeaways

  • Type-level testing prevents API regressions by catching type signature changes at compile time, before they reach consumers in libraries or shared packages.
  • expectTypeOf in Vitest provides a fluent, type-safe syntax for asserting that expressions and functions have expected types without runtime overhead.
  • Test inferred return types and generic flows to ensure that type inference patterns remain stable as you refactor; this is critical for developer experience in SDKs and utilities.
  • Test negative cases with @ts-expect-error and parameter type assertions to verify that invalid calls are rejected by TypeScript's type-checker.
  • Prioritize type tests for public APIs in shared packages where changes cascade across multiple teams; these tests document your type contract and prevent silent breaking changes.
  • Avoid testing private implementation details and any assertions; keep type tests focused on the observable contract and concrete types.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles