Skip to main content

Type-level testing

Workflow with tsc, vitest @ts-expect-error, and tsd.

duck-ttest does not bundle a runner. The runner is whatever you already use to type-check your project. This page walks through the three most common setups.

Workflow 1: tsc --noEmit

The simplest setup. Put assertions in any file covered by tsconfig.json and add a script:

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

A failing assertion surfaces as a normal TS2344 (constraint not satisfied) error with your message visible on the same line.

src/__type_tests__/user.test-d.ts
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'

type _ = [
  AssertTrue<Equal<1, 1>, '1 equals 1'>,
]

This is the recommended setup for libraries — the type test passes through the same compile that your consumers use.

Workflow 2: vitest + @ts-expect-error

When you already use vitest, drop type tests in .test.ts files next to runtime tests. The compile runs as part of vitest's transform, so failing assertions block the test run.

src/user.test.ts
import { expect, it } from 'vitest'
import type { AssertTrue, AssertFalse } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
import { createUser, type User } from './user'

// Runtime assertions
it('creates a user with a numeric id', () => {
  expect(typeof createUser('Ada').id).toBe('number')
})

// Type assertions — these fail the test run if `createUser`'s return type changes.
type _ = [
  AssertTrue<Equal<ReturnType<typeof createUser>, User>, 'must return User'>,
  AssertTrue<Equal<User['id'], number>, 'User.id is number'>,
]

// Use @ts-expect-error to assert that a call fails to type-check.
// @ts-expect-error createUser requires a string
createUser(123)

The pattern: AssertTrue/AssertFalse for type shape assertions, @ts-expect-error for invalid usage assertions.

Workflow 3: tsd

If you publish a .d.ts and want a CLI report listing every assertion line, tsd and duck-ttest compose:

test-d/index.test-d.ts
import { expectType } from 'tsd'
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
import { add } from '../src'

// tsd-style runtime hint:
expectType<number>(add(1, 2))

// duck-ttest type-only assertion:
type _ = AssertTrue<Equal<ReturnType<typeof add>, number>, 'add returns number'>

The two styles are complementary — expectType and AssertTrue<Equal> both compile to nothing.

Workflow 4: expect-type

expect-type is a runner-free expectTypeOf API similar to tsd. It composes well with duck-ttest — use expectTypeOf for inline runtime-position checks and AssertTrue<Equal<...>> for the standalone shape-level suite.

src/__type_tests__/api.test.ts
import { expectTypeOf } from 'expect-type'
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
import { fetchUser, type User } from '../api'

// expect-type style: inline, value-position
expectTypeOf(fetchUser).returns.resolves.toEqualTypeOf<User>()
expectTypeOf<User['id']>().toEqualTypeOf<number>()

// duck-ttest style: declarative, type-position
type _ = [
  AssertTrue<Equal<Awaited<ReturnType<typeof fetchUser>>, User>, 'fetchUser resolves to User'>,
  AssertTrue<Equal<User['id'], number>, 'User.id is number'>,
]

Pick expectTypeOf when the check sits next to a value you already have; pick AssertTrue<Equal<...>> when the check stands alone or belongs in a long grouped tuple.

Which workflow to pick

SituationRecommended
Library authortsc --noEmit same compile as your consumers.
App with vitestvitest + @ts-expect-error co-locate with runtime tests.
Publishing a .d.ts packagetsd for assertion line reports; duck-ttest for the utilities.
Inline value-position checksexpect-type for the expectTypeOf(value).toEqualTypeOf<T>() form.
Monorepoturbo run check-types calling tsc --noEmit per package.

Negative tests

There are two ways to assert "this should not type-check":

  1. @ts-expect-error — checked inline, at the call site. Best for invalid runtime calls.
  2. AssertFalse<P<X>, Msg> — checked declaratively. Best for invalid type shapes.
import type { AssertFalse } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'

// shape assertion — declarative
type _shape = AssertFalse<Equal<string, number>, 'string != number'>

// usage assertion — inline
// @ts-expect-error number argument should not be accepted
acceptsString(42)

Performance

Type-level tests are usually cheap, but a few patterns explode the type-checker:

  • Recursive utility calls (e.g. nesting MatchesGlob inside Reduce) — fine in isolation, expensive when fanned out across hundreds of assertions.
  • Wide Paths<T> calls — cap depth via the second generic.
  • MatchRoute over deep paths — same; segmentation is recursive.

The fix is usually module-level: split test files so each one only imports the sub-paths it actually uses.

See also