Skip to main content

Composing tests

Naming, organization, and the pitfalls of branding inside assertions.

A page of accumulated rules from real codebases.

File naming

Pick one of:

  • *.test-d.ts — convention borrowed from tsd. Good when type tests are separate from runtime tests.
  • *.test.ts — co-locate with runtime tests when using vitest / jest / bun-test.
  • *.types.test.ts — explicit, no ambiguity.

duck-ttest does not care which you pick — the runner is tsc. Pick one and stick to it across the project.

Group with tuples

A single tuple type acts as a "describe block":

type userTests = [
  AssertTrue<Equal<User['id'], number>, 'id is number'>,
  AssertTrue<Equal<User['email'], string>, 'email is string'>,
  AssertFalse<Equal<User, undefined>, 'User defined'>,
]

The compiler reports the tuple-element index when something breaks. Names like userTests, schemaTests, routerTests map cleanly to vitest describe blocks if you mix the two.

Name assertions by what they assert

Compare:

// Vague
type t1 = AssertTrue<Equal<X, Y>, 'works'>

// Clear
type _Subject_Id_Is_Brand = AssertTrue<Equal<X, Y>, 'Subject.id should be Brand<string, "SubjectId">'>

The message goes into the error; the type alias name goes into the IDE hover. Make both useful.

Brand-aware assertions

Branded types do not equal their base. This is by design — but it's a common source of confusing failures:

import type { Brand } from '@gentleduck/ttest/brand'
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'

type UserId = Brand<string, 'UserId'>

// FAILS — UserId is not equal to string.
type bad = AssertTrue<Equal<UserId, string>, 'no'>

// Use Unbrand or assert against the brand explicitly.
import type { Unbrand } from '@gentleduck/ttest/brand'
type good = AssertTrue<Equal<Unbrand<UserId>, string>, 'unbranded matches base'>

any is contagious

Once any appears anywhere inside a generic argument, Equal cooperates with you (returns false against most non-any types). But this can also produce false passes if you're not careful:

type wrong = AssertTrue<Equal<any, any>, 'always passes'>
//                       ^ both sides are any — Equal returns true

Always test against the expected concrete type, never any. If you must, use IsAny from @gentleduck/ttest/any:

import type { IsAny } from '@gentleduck/ttest/any'
type leaky = AssertFalse<IsAny<MyApiResponse>, 'API response leaked any'>

One assertion per concern

If you find yourself reading Equal<X & Y, Z & W>, split it:

// Hard to read on failure
type combined = AssertTrue<Equal<User & Admin, AdminUser>, 'merge works'>

// Each piece reports its own failure
type _ = [
  AssertTrue<Equal<AdminUser['id'], User['id']>, 'id preserved'>,
  AssertTrue<Equal<AdminUser['role'], Admin['role']>, 'role added'>,
  AssertTrue<Equal<keyof AdminUser, keyof User | keyof Admin>, 'keys merged'>,
]

Distribution traps

Naked conditional types distribute over unions. This is usually what you want, but inside an assertion it often isn't:

import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'

type Wrap<T> = T extends unknown ? [T] : never

type bad = AssertTrue<Equal<Wrap<'a' | 'b'>, ['a' | 'b']>, 'wraps the union'>
//                                ^ FAILS — Wrap distributes, so the LHS is `['a'] | ['b']`

The fix is the standard [T] extends [U] wrap that disables distribution:

type WrapNoDist<T> = [T] extends [unknown] ? [T] : never

type good = AssertTrue<Equal<WrapNoDist<'a' | 'b'>, ['a' | 'b']>, 'wraps once'>

A similar trap shows up around nevernever distributes to never, so T extends X ? Y : Z with T = never is just never. Always wrap in [T] if the assertion's correctness depends on never being a single inhabited type.

Keep test files leaf-only

If a .test-d.ts re-exports types that production code imports, you've created a circular path. Treat test files as leaves of the import graph — they import production types, never the other way around.

When tests get slow

A few signs:

  • tsc --noEmit jumps from < 1s to 5+ s after adding a test file.
  • Editor hovers freeze on the file.
  • IDE auto-import suggestions disappear.

Common culprits:

  • A Reduce<...> or Match<...> over a long tuple where the per-element type-function is itself non-trivial.
  • A Paths<T> against a deeply nested object without the depth cap.
  • A MatchesGlob<S, '**'> over a wide string union.

The fix is usually to extract the helper type and parameterize the test rather than reconstructing it inline.

See also