Catching regressions
Fold type tests into vitest, CI, and pre-commit hooks.
Type tests are most valuable when they run on every change. This guide shows the wiring.
In a vitest project
duck-ttest assertions compile through vitest's TypeScript transform — failing assertions block the test run with the message in the diagnostic.
import { describe, it, expect } from 'vitest'
import type { AssertTrue, AssertFalse } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
import { createUser, type User } from './user'
describe('createUser', () => {
it('returns a User', () => {
const u = createUser('Ada')
expect(u).toMatchObject({ name: 'Ada' })
})
// type assertions — these break the build, not the test
type _ = [
AssertTrue<Equal<ReturnType<typeof createUser>, User>, 'must return User'>,
AssertTrue<Equal<User['name'], string>, 'name is string'>,
]
})
In a tsc CI step
Most reliable for libraries — the same compile your consumers run.
{
"scripts": {
"test:types": "tsc --noEmit",
"test": "vitest && bun run test:types"
}
}
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun run test
In a monorepo with turbo
{
"tasks": {
"check-types": {
"dependsOn": ["^check-types"],
"outputs": []
}
}
}
turbo run check-types
Each package gets its own type-check; failures surface per package.
In a pre-commit hook
For libraries that publish types, run on staged files only:
#!/usr/bin/env sh
bun run lint-staged
bun run test:types
If test:types regularly takes more than a few seconds, scope it to the changed package instead of the whole repo.
Stable test names
Compiler errors point at the file and line. Name your assertion-bundle types so the IDE hover tells the story:
type _Subject_Id_Is_Branded = AssertTrue<
Equal<Subject['id'], UserId>,
'Subject.id must be the branded UserId, not raw string'
>
When this fails, two things help: the type alias name (in the editor hover) and the message string (in the diagnostic).
Detecting any leaks
any silently passes most equality checks. Guard against it explicitly:
import type { AssertFalse } from '@gentleduck/ttest/assert'
import type { IsAny } from '@gentleduck/ttest/any'
type _no_leaks = [
AssertFalse<IsAny<User['id']>, 'User.id leaked any'>,
AssertFalse<IsAny<User['email']>, 'User.email leaked any'>,
AssertFalse<IsAny<UserRow['name']>, 'UserRow.name leaked any'>,
]
Add one of these per public type that takes a tight contract. A future generic loosening that resolves to any will trip the assertion.
Detecting accidental widening
When you constrain a public API with as const or Narrow, regressions show up as widened literals.
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
const ACTIONS = ['create', 'read', 'update', 'delete'] as const
type Action = typeof ACTIONS[number]
type _ = AssertTrue<
Equal<Action, 'create' | 'read' | 'update' | 'delete'>,
'ACTIONS widened to string — did someone drop the `as const`?'
>
If a refactor drops as const, Action becomes string and the assertion fires.
Anti-patterns
Assertions that pass for the wrong reason
// Both sides resolve to `any` — the assertion is vacuous.
type _ = AssertTrue<Equal<typeof someApi, any>, 'api shape'>
Pair every potentially-any assertion with an IsAny guard. Otherwise a future refactor that loosens the contract to any keeps the assertion green.
type _ = [
AssertFalse<IsAny<typeof someApi>, 'api shape must not be any'>,
AssertTrue <Equal<typeof someApi, ApiShape>, 'api shape matches contract'>,
]
Putting type tests in the same file as the runtime exports
// src/user.ts
export type User = { /* ... */ }
export function createUser(name: string): User { /* ... */ }
// Anti-pattern: same file, mixed concern.
type _ = AssertTrue<Equal<ReturnType<typeof createUser>, User>, 'returns User'>
The assertion compiles into the published artifact. Move it to user.test-d.ts or user.test.ts — files that production code never imports.
Asserting against runtime values
const ROLES = ['admin', 'editor'] // no `as const`
type _ = AssertTrue<Equal<typeof ROLES[number], 'admin' | 'editor'>, 'roles'>
// FAILS — `ROLES` widens to `string[]`, so the inferred member is `string`.
The fix is at the runtime declaration: ['admin', 'editor'] as const. The assertion then catches future drift, not the present-day declaration sloppiness.
See also
- Core / Type-level testing — the three runner workflows.
- Core / Composing tests — naming, grouping, and performance.
- API / any —
IsAnyand friends.