Getting started
Write a type-level test, see it fail, fix it. Five minutes.
This page walks through a single failing test, the error it produces, and how to fix it. Everything happens at compile time — there is no test runner to install and no command to add to package.json beyond tsc --noEmit.
1. Set up a test folder
Create a folder for type tests anywhere covered by your tsconfig.json. A common convention:
src/
__type_tests__/
user.test-d.ts
schema.test-d.ts
The .test-d.ts suffix is purely a convention — duck-ttest doesn't care about file names.
2. Write a passing test
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
type User = { id: number; name: string }
type _Id_Is_Number = AssertTrue<
Equal<User['id'], number>,
'User.id should be number'
>
tsc --noEmit reports no errors. Done.
3. Make it fail
Change the assertion target:
type _Id_Is_Number = AssertTrue<
Equal<User['id'], string>,
'User.id should be number'
>
Run tsc --noEmit:
src/__type_tests__/user.test-d.ts:5:31 - error TS2344:
Type 'false' does not satisfy the constraint 'true'.
AssertTrue<T extends true, Msg extends string> constrains T to true. When the constraint fails, the compiler points at the assertion line, and the second generic — your message — is right there in the source.
4. Use the message
The message becomes useful when you have a wall of assertions:
type assertions = [
AssertTrue<Equal<User['id'], number>, 'User.id should be number'>,
AssertTrue<Equal<User['name'], string>, 'User.name should be string'>,
AssertTrue<Equal<keyof User, 'id' | 'name'>, 'User keys should be id and name'>,
]
When any one of these breaks, the error includes the surrounding tuple index — narrow it down with the message string.
5. Test that something is not assignable
AssertFalse flips the polarity:
import type { AssertFalse } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
type _Number_Is_Not_String = AssertFalse<
Equal<number, string>,
'number must not equal string'
>
For pure assignability use IfExtends from @gentleduck/ttest/conditional:
import type { IfExtends } from '@gentleduck/ttest/conditional'
type _Owner_Subset = AssertTrue<
IfExtends<'admin', 'admin' | 'editor' | 'viewer', true, false>,
'admin should be a member of role union'
>
6. Wire it into CI
Add a script to package.json:
{
"scripts": {
"test:types": "tsc --noEmit"
}
}
Then run it in CI:
- run: bun install
- run: bun run test:types
That's the whole runner.
7. Test alongside runtime behavior with vitest
If you already use vitest, drop type tests right next to runtime tests:
import { expect, it } from 'vitest'
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
import { createUser, type User } from './user'
it('creates a user', () => {
expect(createUser('a')).toEqual({ id: expect.any(Number), name: 'a' })
})
// Compile-time invariants:
type _ = [
AssertTrue<Equal<ReturnType<typeof createUser>, User>, 'must return User'>,
]
Vitest runs the runtime test; tsc runs the type test. They live in the same file.
Where next
- Core / Assertions — every assertion, with edge cases.
- Core / Type-level testing — how this composes with
@ts-expect-errorandtsd. - Guides / Catching regressions — folding type tests into a vitest project.