Skip to content

TypeScript type testing with a fast CLI tool and a smooth WYSIWYG editor experience.

License

Notifications You must be signed in to change notification settings

Snowflyt/typroof

Folders and files

NameName
Last commit message
Last commit date

Latest commit

cf9345e · Mar 19, 2025
Mar 9, 2025
Mar 9, 2025
Mar 19, 2025
Mar 19, 2025
Mar 19, 2025
Mar 19, 2025
Nov 16, 2023
Nov 16, 2023
Mar 9, 2025
Mar 19, 2025
Mar 9, 2025
Mar 9, 2025
Mar 19, 2025
Mar 19, 2025
Mar 9, 2025
Dec 26, 2023
Mar 9, 2025
Jan 13, 2024
Jul 2, 2024

Repository files navigation

Typroof

TypeScript type testing with a fast CLI tool and a smooth WYSIWYG editor experience.

downloads version test license

demo.mp4

Installation

npm install --save-dev typroof

Quickstart

Define your types

Assume that you write a string-utils.ts file with the following type definitions:

export type Append<S extends string, Ext extends string> = `${S}${Ext}`;
export type Prepend<S extends string, Start extends string> = `${Start}${S}`;

export const append = <S extends string, Ext extends string>(s: S, ext: Ext): Append<S, Ext> =>
  `${s}${ext}`;

Add a type test

Create a string-utils.proof.ts file in the same directory to test them:

import { describe, equal, error, expect, extend, it, test } from 'typroof';
import { append } from './string-utils';
import type { Append, Prepend } from './string-utils';

test('Append', () => {
  expect<Append<'foo', 'bar'>>().to(equal<'foo'>);
  expect<Append<'foo', 'bar'>>().to(extend<string>);
  expect<Append<'foo', 'bar'>>().not.to(extend<number>);
  expect(append('foo', 'bar')).to(equal('foobar' as const));
});

Oops! Seems we have made a mistake, and TypeScript language server is already showing you the error message in your editor:

expect<Append<'foo', 'bar'>>().to(equal<'foo'>);
//                                ~~~~~~~~~~~~
//            Argument of type '...' is not assignable to parameter
//         of type '"Expect `'foobar'` to equal `'foo'`, but does not"'

This is the WYSIWYG editor experience Typroof provides—instant feedback right in your editor.

Let’s ignore this error for now and see what happens when we run the tests.

Run the CLI

Run typroof to test your type definitions:

npx typroof

You’ll see the error clearly reported:

❯ src/string-utils.proof.ts (1)
  × Append
    ❯ src/string-utils.proof.ts:6:37
      Expect Append<'foo', 'bar'> to equal "foo", but got "foobar".

 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  17:50:11
   Duration  2ms

Let’s fix the error in the test file:

- expect<Append<'foo', 'bar'>>().to(equal<'foo'>);
+ expect<Append<'foo', 'bar'>>().to(equal<'foobar'>);

Success! You’ve written and verified your first type test with Typroof. 🎉

✓ src/string-utils.proof.ts (1)
  ✓ Append

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:51:26
   Duration  2ms

Usage

After getting started with Typroof, let’s explore its core concepts and patterns in more depth.

API Overview

Typroof provides a familiar testing API that resembles Jest:

  • test/it: Create individual test cases
  • describe: Group related tests together
  • expect: Create an assertion on a type or value
  • .to(matcher): Apply a matcher to validate the assertion
  • .not.to(matcher): Negate a matcher expectation

Assertion Patterns

Typroof offers flexible ways to write assertions:

// Testing a type directly (most common for type utilities)
expect<MyType<Input>>().to(equal<Expected>);

// Testing a value’s type (useful for functions)
expect(myFunction('input')).to(equal<ExpectedReturnType>);

// Negating an assertion
expect<MyType>().not.to(beAny);

// Testing for errors
// @ts-expect-error
expect<InvalidType>().to(error);

Matcher Flexibility

Matchers can receive expected types in two ways:

// As a type parameter (preferred for testing generic types)
expect<MyType<'input'>>().to(equal<'expected'>);

// As a value parameter (useful for function return types)
const result = computeSomething();
const expected = computeSomethingElse();
expect(result).to(equal(expected));

Composing Tests

describe('StringUtils', () => {
  describe('Append', () => {
    it('concatenates two strings', () => {
      expect<Append<'hello', ' world'>>().to(equal<'hello world'>);
    });

    it('returns a string type', () => {
      expect<Append<'a', 'b'>>().to(extend<string>);
    });
  });

  describe('Split', () => {
    it('splits a string into tuple', () => {
      expect<Split<'a-b-c', '-'>>().to(equal<['a', 'b', 'c']>);
    });
  });
});

Testing For Type Errors

Test that invalid types correctly produce errors:

describe('NumericUtilities', () => {
  it('rejects non-numeric inputs', () => {
    // @ts-expect-error - string is not assignable to number
    expect<Add<'not-a-number', 5>>().to(error);
  });
});

Running Tests

Run tests with the Typroof CLI:

npx typroof [optional path]

By default, Typroof will find all .proof.ts files or files in proof/ directories and analyze them. To customize which files to test, you can create a typroof.config.ts file in the root directory of your project. See Configuration for more information.

Matchers

Matchers are the core of Typroof’s assertion system. They let you validate type relationships and characteristics in your tests.

Matcher Basics

Each matcher can be used with the expect().to() or expect().not.to() syntax:

// Basic matcher usage
expect<MyType>().to(matcherName<OptionalTypeArg>);

// Negated matcher usage
expect<MyType>().not.to(matcherName<OptionalTypeArg>);

Built-in Matchers

Typroof provides two categories of matchers for different testing needs:

Equality and Basic Type Matchers

Matcher Description Example
equal<T> Checks for exact type equality expect<'hello'>().to(equal<'hello'>)
error Verifies a type produces a compilation error expect<ConcatString<'foo', 42>>().to(error)
beAny Checks if a type is any expect<any>().to(beAny)
beNever Checks if a type is never expect<never>().to(beNever)
beNull Checks if a type is null expect<null>().to(beNull)
beUndefined Checks if a type is undefined expect<undefined>().to(beUndefined)
beNullish Checks if a type is null, undefined or their union expect<null | undefined>().to(beNullish)
beTrue/beFalse Checks if a type is true/false expect<true>().to(beTrue)
matchBoolean Checks if a type is true, false or boolean expect<boolean>().to(matchBoolean)

Type Relationship Matchers

Matcher Description Example
extend<T> Checks if a type is assignable to another expect<'hello'>().to(extend<string>)
strictExtend<T> Like extend but stricter with any/never expect<string>().to(strictExtend<string>)
cover<T> Checks if a type is a supertype of another expect<string>().to(cover<'hello'>)
strictCover<T> Like cover but stricter with any/never expect<string>().to(strictCover<'hello'>)

Matcher Examples

Testing type utilities:

// Testing a string template utility
type Prefix<T extends string, P extends string> = `${P}${T}`;

test('Prefix type', () => {
  expect<Prefix<'World', 'Hello '>>().to(equal<'Hello World'>);
  expect<Prefix<'file', 'index.'>>().to(extend<string>);
  expect<Prefix<'foo', 'bar'>>().not.to(equal<'foobar'>);
});

Testing for compilation errors:

// Testing constraint violations
test('NumericId constraints', () => {
  type NumericId<T extends number> = T;

  expect<NumericId<42>>().to(extend<number>);

  // @ts-expect-error - String not assignable to number
  expect<NumericId<'42'>>().to(error);
});

Understanding Special Types

TypeScript’s any and never types have special behavior in type relationships:

  • any is both a subtype and supertype of all types.
  • never is a subtype of all types but has no subtypes.

This can lead to unexpected results in type tests:

// Regular extend allows any to be assigned to anything
expect<any>().to(extend<string>); // passes

// strictExtend prevents this
expect<any>().not.to(strictExtend<string>); // passes

For more predictable behavior with these types, use strictExtend and strictCover when testing type relationships involving any or never.

Configuration

Typroof can be customized through a configuration file to control which files are tested and how tests are run.

Configuration File

Create a typroof.config.ts file in your project root:

import { defineConfig } from 'typroof/config';

export default defineConfig({
  testFiles: '**/*.types.test.ts',
});

You can use either .ts, .mts, .cts, .js, .mjs or .cjs as the extension of the config file. The priority is .ts > .mts > .cts > .js > .mjs > .cjs.

Available Options

Option Type Default Description
tsConfigFilePath string './tsconfig.json' Path to the TypeScript configuration file
testFiles string | string[] ['**/*.proof.{ts,tsx}', 'proof/**/*.{ts,tsx}'] Glob pattern(s) for test files
compilerOptions ts.CompilerOptions {} Additional TypeScript compiler options to override those in tsconfig.json
plugins Plugin[] [] Typroof plugins that extend functionality

Import Notice

When creating your configuration file, import from the specific subpath:

// ✅ Correct import
import { defineConfig } from 'typroof/config';

CLI Usage

You can run Typroof from the command line:

# Run in current directory
npx typroof

# Run in specific directory
npx typroof /path/to/project

# Specify test files
npx typroof --files "src/**/*.proof.ts"

# Use custom config file
npx typroof --config ./typroof.config.ts

# Get help
npx typroof --help

Available options:

  • --files, -f: Glob pattern(s) for test files.
  • --config, -c: Path to config file.
  • --help: Show help information.
  • --version: Show version information.

Programmatic Usage

You can use Typroof programmatically within your own Node.js scripts or tools:

import typroof, { formatGroupResult, formatSummary } from 'typroof';

// Run Typroof with default options
const startedAt = new Date();
const results = await typroof();
const finishedAt = new Date();

// Display results
for (const result of results) {
  console.log(formatGroupResult(result.rootGroupResult));
  console.log();
}

// Print summary
console.log(
  formatSummary({ groups: results.map((r) => r.rootGroupResult), startedAt, finishedAt }),
);

API Options

The typroof() function accepts the same options available in the configuration file, plus an additional cwd option:

// Run with custom options
const results = await typroof({
  // Standard config options
  testFiles: ['src/**/*.proof.ts'],
  compilerOptions: { strictNullChecks: true },
  plugins: [],
  // Additional API-only option
  cwd: '/path/to/project', // Custom working directory
});

The cwd option sets the base directory for:

  • Finding the default tsconfig.json file (${cwd}/tsconfig.json).
  • Resolving relative paths in your configuration.

If not provided, cwd defaults to process.cwd().

Plugin API & How It Works

Typroof supports plugins to extend its functionality with custom matchers. The plugin system allows you to:

  • Create custom type matchers.
  • Share matchers as reusable packages.
  • Extend Typroof’s core functionality.

Creating a Custom Matcher

A matcher in Typroof consists of two parts:

  1. Type validator: A type-level function that checks relationships at compile time.
  2. Analyzer: A runtime function that further analyzes types using the TypeScript compiler API, and reports errors in CLI.

Note that many built-in matchers in Typroof are type-level only matchers whose analyzers are only used to report errors, e.g., equal, extend, beNever, etc. While some matchers require runtime analysis in its analyzer, e.g., error, which checks if a type emits an error with TypeScript compiler API.

Here’s a simple custom matcher example (a type-level only matcher):

import { match } from 'typroof/plugin';
import type { Actual, Expected, Validator } from 'typroof/plugin';

// 1. Create and export the matcher
export const startsWith = <U extends string>(prefix?: U) => match<'startsWith', U>();

// 2. Define the validator type
declare module 'typroof/plugin' {
  interface ValidatorRegistry {
    startsWith: StartsWithValidator;
  }
}
type Cast<T, U> = T extends U ? T : U;
// Use a type-level function (i.e. HKT) to define a type-level validator
interface StartsWithValidator extends Validator {
  // Return `true` or `false` to indicate whether the assertion passed or not
  return: Actual<this> extends `${Cast<Expected<this>, string>}${string}` ? true : false;
}

// 3. Create a plugin to register the analyzer
import type { Plugin } from 'typroof/plugin';

export const startsWith = (): Plugin => ({
  name: 'typroof-plugin-starts-with',
  analyzers: {
    // `actual` and `expected` are the types passed to the matcher (T and U).
    startsWith: (actual, expected, { not, typeChecker }) => {
      // NOTE: This analyzer is only called when the type-level validation fails
      // We use TypeScript compiler API to get the text of the type:
      const actualType = typeChecker.typeToString(actual.type);
      const expectedType = typeChecker.typeToString(expected);
      // Throw a string to report the error
      throw `Expected ${actual.text} ${not ? 'not ' : ''}to start with "${expectedType}", but got "${actualType}"`;
    },
  },
});

// 4. Use the plugin in your config
// typroof.config.ts
import { defineConfig } from 'typroof/config';

export default defineConfig({
  plugins: [startsWith()],
});

Validators in Typroof are type-level functions (HKTs) compatible with the hkt-core V1 standard, see its documentation for more information.

Deep Dive: Custom Error Messages for Validators

In the previous example, Typroof already automatically generates a compile-time error message if the assertion fails:

expect<'foobar'>().to(startsWith<'bar'>);
//                    ~~~~~~~~~~~~~~~~~
//    Argument of type '...' is not assignable to parameter
//   of type "Validation failed: startsWith<'foobar', 'bar'>"

However, it’s not very readable, compared to Typroof’s built-in matchers:

expect<Append<'foo', 'bar'>>().to(equal<'foo'>);
//                                ~~~~~~~~~~~~
//            Argument of type '...' is not assignable to parameter
//          of type "Expect `'foobar'` to equal `'foo'`, but does not"

The magic behind this is that the equal matcher’s validator returns a string type instead of a boolean type if the assertion fails, which is used as the error message.

Let’s rewrite the startsWith matcher to return a string type as the error message:

import type { Actual, Expected, IsNegated, Stringify, Validator } from 'typroof/plugin';

declare module 'typroof/plugin' {
  interface ValidatorRegistry {
    startsWith: StartsWithValidator;
  }
}

interface StartsWithValidator extends Validator {
  // `IsNegated<this>` is `true` if `.not` is used, otherwise `false`.
  return: IsNegated<this> extends false ?
    // If `.not` is not used
    Actual<this> extends `${Cast<Expected<this>, string>}${string}` ?
      true
    : `Expect \`${Stringify<Actual<this>>}\` to start with \`${Stringify<Expected<this>>}\`, but does not`
  : // If `.not` is used
  Actual<this> extends `${Cast<Expected<this>, string>}${string}` ? false
  : `Expect the type not to start with \`${Stringify<Expected<this>>}\`, but does`;
}

The exported Stringify utility type is used to convert a type to a literal string type, which is used as the error message. The implementation of Stringify is quite complex, and it is recommended to use it instead of implementing your own.

Tip

Stringify supports custom serializers. Say you have a custom type interface Response<T> { code: number; data: T }. Instead of receiving "{ code: number; data: string }" as the result of Stringify<Response<string>>, you might prefer the more concise "Response<string>". You can achieve this by adding a custom serializer to Stringify via the module augmentation syntax:

import type { Serializer, Stringify, Type } from 'typroof/plugin';

declare module 'typroof/plugin' {
  interface StringifySerializerRegistry {
    Response: { if: ['extends', Response<unknown>]; serializer: ResponseSerializer };
  }
}
interface ResponseSerializer extends Serializer<Response<unknown>> {
  return: `Response<${Type<this>['data']}>`;
}

type TestResult = Stringify<Response<string>>;
//   ^?: "Response<string>"

Similar to Validators, Serializers are also type-level function but return a string type. The Type<this> utility type is used to get the type passed to the current serializer. Except for the serializer property, you also have to add a if property as a predicate to determine whether the serializer should be used. Valid forms of the if property are:

  • ['extends', T]: The type must extend T.
  • ['equals', T]: The type must be exactly equal to T.
  • A custom type-level function (HKT) that returns a boolean type. See the documentation of hkt-core for more information.

Custom serializers also boost Stringify utility’s speed in computing results, which can prevent Typroof from slowing down or crashing when handling complex types.

Deep Dive: Advanced Example - error Matcher

Up to now, we have seen how to create a type-level only matcher. But what if we want to create a matcher that requires runtime analysis?

error matcher checks if a type emits an error with TypeScript compiler API. It is a good example to show how to create a matcher that requires runtime analysis.

Let’s take a look at how error is implemented:

import { match } from 'typroof/plugin';

import type { ToAnalyze } from 'typroof/plugin';

export const error = match<'error'>();

declare module 'typroof/plugin' {
  interface ValidatorRegistry {
    error: ErrorValidator;
  }
}
interface ErrorValidator {
  return: ToAnalyze<never>;
}

export const errorPlugin = (): Plugin => ({
  name: 'typroof-plugin-error',
  analyzers: {
    error: (actual, _expected, { diagnostics, not, sourceFile, statement }) => {
      // Check if a diagnostic error exists for this node
      const diagnostic = diagnostics.find((diagnostic) => {
        const start = diagnostic.start;
        if (start === undefined) return false;

        const length = diagnostic.length;
        if (length === undefined) return false;

        const end = start + length;
        const nodeStart = actual.node.getStart(sourceFile);
        const nodeEnd = actual.node.getEnd();

        return start >= nodeStart && end <= nodeEnd;
      });

      // Find @ts-expect-error comments that apply to this expression
      const findTSExpectError = () => {
        const sourceText = sourceFile.text;

        // 1. Check for leading comments directly before the statement
        const leadingComments =
          ts.getLeadingCommentRanges(sourceText, statement.getFullStart()) || [];

        // 2. Find any internal comments within the statement’s full text range
        // This helps with multi-line expressions that have inline comments
        const statementStart = statement.getFullStart();
        const statementEnd = statement.getEnd();
        const statementText = sourceText.substring(statementStart, statementEnd);

        // Track all potential comment positions
        const commentPositions: { start: number; end: number }[] = [
          ...leadingComments.map((c) => ({ start: c.pos, end: c.end })),
        ];

        // Scan the statement for possible comment starts
        let pos = 0;
        while (pos < statementText.length) {
          // Look for // comments
          if (statementText.substring(pos, pos + 2) === '//') {
            const startPos = statementStart + pos;
            let endPos = statementText.indexOf('\n', pos);
            if (endPos === -1) endPos = statementText.length;
            commentPositions.push({
              start: startPos,
              end: statementStart + endPos,
            });
            pos = endPos + 1;
            continue;
          }

          // Look for /* */ comments
          if (statementText.substring(pos, pos + 2) === '/*') {
            const startPos = statementStart + pos;
            const endPos = statementText.indexOf('*/', pos);
            if (endPos !== -1) {
              commentPositions.push({
                start: startPos,
                end: statementStart + endPos + 2,
              });
              pos = endPos + 2;
              continue;
            }
          }

          pos++;
        }

        // Check all comment positions for @ts-expect-error
        for (const { end, start } of commentPositions) {
          const commentText = sourceText.substring(start, end);
          if (commentText.includes('@ts-expect-error')) {
            // Ensure this @ts-expect-error is not already used by checking
            // if there’s a diagnostic that starts at this exact position
            const isUnused = !diagnostics.some(
              (d) => d.start === start && d.code === 2578, // TypeScript’s code for @ts-expect-error
            );
            if (isUnused) return true;
          }
        }

        return false;
      };

      // Check if error is triggered either by diagnostic or @ts-expect-error
      const triggeredError = !!diagnostic || findTSExpectError();

      if (not ? triggeredError : !triggeredError) {
        const actualText = bold(actual.text);
        throw (
          `Expect ${actualText} ${not ? 'not ' : ''}to trigger error, ` +
          `but ${not ? 'did' : 'did not'}.`
        );
      }
    },
  },
});

The error matcher returns ToAnalyze<never> as the return type of its validator, which means it requires runtime analysis. In the error example, the type-level validation step is omitted, so we simply pass ToAnalyze<never>. However, if you want to create a matcher that requires both type-level validation and runtime analysis, you can return ToAnalyze<ValidatorReturnType> as the return type of your validator—the validation result type can be accessed via validationResult in the 3rd argument of the analyzer.

You can still return booleans or strings as the return type of your validator in combination with ToAnalyze<ValidatorReturnType>, where the boolean or string indicates an early exit of the type-level validation step, and validationResult will be undefined in the analyzer if false or string is returned from the validator.

Publishing a Plugin

If you want to publish your plugin as a library, it is recommended to export the factory function to create the plugin object as the default export, and export the matchers as named exports:

// In your `index.ts`
import { match } from 'typroof/plugin';

import type { Plugin } from 'typroof/plugin';

declare module 'typroof/plugin' {
  interface ValidatorRegistry {
    beFoo: /* ... */;
  }
}

const foo = (): Plugin => ({
  /* ... */
});
export default foo;

/**
 * [Matcher] Expect the type to be `"foo"`.
 */
export const beFoo = match<'beFoo'>();

Then your users can use your plugin like this:

// In their `typroof.config.ts`
import foo from 'typroof-plugin-example';

export default defineConfig({
  plugins: [foo()],
});

// And somewhere in their test files
import { beFoo } from 'typroof-plugin-example';

test('foo', () => {
  expect<'foo'>().to(beFoo);
});

You can try a live demo of creating a plugin here.