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 compilerexpectTypeOf— 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 = stringhere.” - “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
wrapto useany, 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 thatexpris assignable toX.- If TypeScript infers something else,
tsdfails.
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('...')→ OKparseUser(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:
expectTypeOfis erased at runtime.- TypeScript checks the assertion during compilation.
- If
createResponsechanges 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:
tsdis like “type‑only unit tests for libraries”.expectTypeOfis like “type assertions embedded in your normal test suite”.