TypeScript type testing with a fast CLI tool and a smooth WYSIWYG editor experience.
demo.mp4
npm install --save-dev typroof
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}`;
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 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
After getting started with Typroof, let’s explore its core concepts and patterns in more depth.
Typroof provides a familiar testing API that resembles Jest:
test
/it
: Create individual test casesdescribe
: Group related tests togetherexpect
: Create an assertion on a type or value.to(matcher)
: Apply a matcher to validate the assertion.not.to(matcher)
: Negate a matcher expectation
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);
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));
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']>);
});
});
});
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);
});
});
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 are the core of Typroof’s assertion system. They let you validate type relationships and characteristics in your tests.
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>);
Typroof provides two categories of matchers for different testing needs:
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) |
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'>) |
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);
});
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
.
Typroof can be customized through a configuration file to control which files are tested and how tests are run.
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
.
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 |
When creating your configuration file, import from the specific subpath:
// ✅ Correct import
import { defineConfig } from 'typroof/config';
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.
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 }),
);
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()
.
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.
A matcher in Typroof consists of two parts:
- Type validator: A type-level function that checks relationships at compile time.
- 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()],
});
Validator
s in Typroof are type-level functions (HKTs) compatible with the hkt-core V1 standard, see its documentation for more information.
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 Validator
s, Serializer
s 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 extendT
.['equals', T]
: The type must be exactly equal toT
.- 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.
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.
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.