Skip to content

Type Testing

So far, you’ve tested behavior:

“Given these inputs, does this function return the right value?”

In TypeScript, there’s a second dimension you can (and should) test:

Type behavior — “Given this code, what does the compiler think the types are?”

Type tests don’t run at runtime.

They don’t assert values.

They assert what TypeScript’s type system should accept, reject, or infer.

This matters a lot when:

  • you publish a library or SDK
  • you expose generic helpers and utility types
  • you rely on inference for ergonomics
  • you want to prevent silent type regressions when refactoring

Two tools are widely used for this:

  • tsd — a dedicated type‑testing tool that runs only the compiler
  • expectTypeOf — type assertions embedded in a normal test runner (Vitest/Jest)

We’ll go through both in a way that a learner can actually follow and apply.


1. What is “type testing” in practice?

Before tools, get the mental model straight.

A type test is you writing code that encodes statements like:

  • “This expression should have type X.”
  • “This call should not compile.”
  • “This generic should infer T = string here.”
  • “This function’s parameters should be [number, string].”

Then you run a tool that:

  • invokes the TypeScript compiler,
  • checks those expectations,
  • and fails if they’re no longer true.

You’re not checking runtime behavior.

You’re checking the shape and inference of types.

This is crucial for:

  • generic APIs
  • conditional types
  • template literal types
  • overloads
  • public library surfaces

2. tsd: compile‑time type tests for libraries and utilities

2.1 When tsd makes sense

Use tsd when:

  • you’re building a library or shared package
  • your types are part of your public API
  • you want a separate, compiler‑only test suite that runs in CI
  • you don’t need runtime assertions in those tests—only type guarantees

Think of tsd as “unit tests for your type signatures”.

2.2 Setup

Install:

npm install --save-dev tsd

In package.json, add:

{
  "scripts": {
    "type:test": "tsd"
  }
}

By convention, tsd looks for *.test-d.ts files.

2.3 Example: testing a generic function’s types

Imagine a small library function:

// src/wrap.ts
export function wrap<T>(value: T) {
  return { value }
}

You want to guarantee:

  • wrap('hi') is { value: string }
  • wrap(123) is { value: number }
  • if someone changes wrap to use any, CI should fail

Create a type test file:

// src/wrap.test-d.ts
import { expectType } from 'tsd'
import { wrap } from './wrap'

expectType<{ value: string }>(wrap('hi'))
expectType<{ value: number }>(wrap(123))

What this means:

  • expectType<X>(expr) asserts that expr is assignable to X.
  • If TypeScript infers something else, tsd fails.

Now run:

npm run type:test

If someone later “simplifies” the function:

export function wrap(value: unknown) {
  return { value }
}

TypeScript now infers { value: unknown }.

Your expectType<{ value: string }>(wrap('hi')) assertion fails.

CI catches the regression.

2.4 Negative tests: asserting “this must not compile”

You also want to assert that invalid usages stay invalid.

Example function:

// src/parseUser.ts
export function parseUser(json: string) {
  return JSON.parse(json) as { id: string; name: string }
}

You want:

  • parseUser('...') → OK
  • parseUser(123) → should be a compile‑time error

In src/parseUser.test-d.ts:

import { expectType } from 'tsd'
import { parseUser } from './parseUser'

expectType<{ id: string; name: string }>(
  parseUser('{"id":"1","name":"Alice"}'),
)

// This call must NOT type-check:
// If it ever does, tsd should fail.
 // @ts-expect-error
parseUser(123)

@ts-expect-error means:

“This line should produce a TypeScript error. If it doesn’t, fail.”

If someone changes parseUser to accept any:

export function parseUser(json: any) {
  // ...
}

Then parseUser(123) no longer errors.

tsd sees that @ts-expect-error is now wrong and fails the test run.

You’ve locked down both:

  • what must be allowed
  • what must stay forbidden

2.5 Testing utility types and inference

tsd is also great for pure type utilities.

Example:

// src/types.ts
export type IdOf<T> = T extends { id: infer I } ? I : never

Type test:

// src/types.test-d.ts
import { expectType } from 'tsd'
import { IdOf } from './types'

type User = { id: string; name: string }

expectType<string>({} as IdOf<User>)
expectType<never>({} as IdOf<{ name: string }>)

You’re verifying that your conditional type behaves as designed.


3. expectTypeOf: type assertions inside Vitest/Jest

3.1 When expectTypeOf makes sense

Use expectTypeOf when:

  • you already have a test runner (Vitest or Jest)
  • you’re building an application or full‑stack project
  • you want type tests next to runtime tests
  • you want devs to see type failures in the same test run they already use

Instead of a separate .test-d.ts world, you keep everything in your normal *.test.ts files.

3.2 With Vitest

Install:

npm install --save-dev vitest

Vitest exposes expectTypeOf (either globally or via import, depending on config).

Example function:

// src/createResponse.ts
export function createResponse<T>(data: T) {
  return { ok: true as const, data }
}

Test:

// src/createResponse.test.ts
import { describe, it, expectTypeOf } from 'vitest'
import { createResponse } from './createResponse'

describe('createResponse', () => {
  it('preserves the type of data', () => {
    const result = createResponse('hello')

    // Type-level assertion:
    expectTypeOf(result).toEqualTypeOf<{ ok: true; data: string }>()
  })
})

What’s happening:

  • expectTypeOf is erased at runtime.
  • TypeScript checks the assertion during compilation.
  • If createResponse changes to return { ok: boolean; data: unknown }, this test fails.

You get type guarantees without leaving your normal test flow.

3.3 Testing inference and function signatures

Where expectTypeOf really shines is inference and signatures.

Say you have:

// src/createMap.ts
export function createMap<T extends string>(values: T[]) {
  return new Map<T, number>()
}

You want:

  • createMap(['a', 'b'])Map<'a' | 'b', number>
  • not Map<string, number>

Test:

import { expectTypeOf } from 'vitest'
import { createMap } from './createMap'

it('infers literal union keys', () => {
  const map = createMap(['a', 'b'] as const)

  expectTypeOf(map).toEqualTypeOf<Map<'a' | 'b', number>>()
})

You’re explicitly locking down the inference behavior.

You can also inspect parameters and return types:

import { expectTypeOf } from 'vitest'
import { createMap } from './createMap'

it('has the expected function signature', () => {
  expectTypeOf(createMap).parameters.toEqualTypeOf<[string[]]>()
  expectTypeOf(createMap).returns.toEqualTypeOf<Map<string, number>>()
})

If someone changes the signature, this test breaks.

3.4 With Jest

If you’re using Jest, you can use the expect-type package:

npm install --save-dev expect-type

Then:

import { expectTypeOf } from 'expect-type'
import { wrap } from './wrap'

test('wrap type', () => {
  expectTypeOf(wrap('hi')).toEqualTypeOf<{ value: string }>()
})

Same idea, different runner.


4. Choosing between tsd and expectTypeOf

This is the decision rule you want learners to internalize.

Use tsd when:

  • You’re building a library, SDK, or shared package.
  • Your types are part of your public contract.
  • You want a dedicated type test suite that runs in CI.
  • You don’t need runtime behavior in those tests—only type guarantees.

Use expectTypeOf when:

  • You’re building an application or full‑stack system.
  • You already use Vitest or Jest.
  • You want type tests alongside runtime tests.
  • You want developers to see type failures in the same test run they already use.

They’re complementary:

  • tsd is like “type‑only unit tests for libraries”.
  • expectTypeOf is like “type assertions embedded in your normal test suite”.