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.