Skip to main content

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

src/__type_tests__/user.test-d.ts
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:

package.json
{
  "scripts": {
    "test:types": "tsc --noEmit"
  }
}

Then run it in CI:

.github/workflows/ci.yml
- 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:

src/user.test.ts
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