Attest
Attest is a testing library that makes your TypeScript types available at runtime, giving you access to precise type-level assertions and performance benchmarks.
Assertions are framework agnostic and can be seamlessly integrated with your existing Vitest, Jest, or Mocha tests.
Benchmarks can run from anywhere and will deterministically report the number of type instantiations contributed by the contents of the bench
call.
If you've ever wondered how ArkType can guarantee identical behavior between its runtime and static parser implementations and highly optimized editor performance, Attest is your answer⚡
Installation
npm install @ark/attest
Note: This package is still in alpha! Your feedback will help us iterate toward a stable 1.0.
Setup
To use attest's type assertions, you'll need to call our setup/cleanup methods before your first test and after your last test, respectively. This usually involves some kind of globalSetup/globalTeardown config.
Vitest
vitest.config.ts
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globalSetup: ["setupVitest.ts"]
}
})
setupVitest.ts
import { setup } from "@ark/attest"
export default () => setup({})
Mocha
package.json
"mocha": {
"require": "./setupMocha.ts"
}
setupMocha.ts
import { setup, teardown } from "@ark/attest"
export const mochaGlobalSetup = () => setup({})
export const mochaGlobalTeardown = teardown
You should also add .attest
to your repository's .gitignore
file.
Bun support is currently pending them supporting @prettier/sync for type formatting. If this is a problem for you, please 👍 that issue so they prioritize it!
Assertions
Here are some simple examples of type assertions and snapshotting:
describe("attest features", () => {
it("type and value assertions", () => {
const even = type("number%2")
attest<number>(even.infer)
attest(even.infer).type.toString.snap("number")
attest(even.json).snap({
intersection: [{ domain: "number" }, { divisor: 2 }]
})
})
it("error assertions", () => {
attest(() => type("number%0")).throwsAndHasTypeError(
"% operator must be followed by a non-zero integer literal (was 0)"
)
attest(() => type({ "[object]": "string" })).type.errors(
"Indexed key definition 'object' must be a string, number or symbol"
)
})
it("completion snapshotting", () => {
attest(() => type({ a: "a", b: "b" })).completions({
a: ["any", "alpha", "alphanumeric"],
b: ["bigint", "boolean"]
})
type Legends = { faker?: "🐐"; [others: string]: unknown }
attest({ "f": "🐐" } as Legends).completions({ "f": ["faker"] })
})
it("jsdoc snapshotting", () => {
const t = type({
foo: "string"
})
const out = t.assert({ foo: "foo" })
attest(out.foo).jsdoc.snap("FOO")
})
it("integrate runtime logic with type assertions", () => {
const arrayOf = type("<t>", "t[]")
const numericArray = arrayOf("number | bigint")
if (getTsVersionUnderTest().startsWith("5")) {
attest<(number | bigint)[]>(numericArray.infer)
}
})
it("integrated type performance benchmarking", () => {
const user = type({
kind: "'admin'",
"powers?": "string[]"
})
.or({
kind: "'superadmin'",
"superpowers?": "string[]"
})
.or({
kind: "'pleb'"
})
attest.instantiations([7574, "instantiations"])
})
})
Options
Options can be specified in one of 3 ways:
- An argument passed to your test process, e.g.
--skipTypes
or --benchPercentThreshold 10
- An environment variable with an
ATTEST_
prefix, e.g. ATTEST_skipTypes=1
or ATTEST_benchPercentThreshold=10
- Passed as an option to attest's
setup
function, e.g.:
setupVitest.ts
import * as attest from "@ark/attest"
export const setup = () =>
attest.setup({
skipTypes: true,
benchPercentThreshold: 10
})
Here are the current defaults for all available options. Please note, some of these are experimental and subject to change:
export const getDefaultAttestConfig = (): BaseAttestConfig => ({
tsconfig:
existsSync(fromCwd("tsconfig.json")) ? fromCwd("tsconfig.json") : undefined,
attestAliases: ["attest", "attestInternal"],
updateSnapshots: false,
skipTypes: false,
skipInlineInstantiations: false,
tsVersions: "typescript",
benchPercentThreshold: 20,
benchErrorOnThresholdExceeded: true,
filter: undefined,
testDeclarationAliases: ["bench", "it", "test"],
formatter: `npm exec --no -- prettier --write`,
shouldFormat: true,
typeToStringFormat: {}
})
skipTypes
skipTypes
is extremely useful for iterating quickly during development without having to typecheck your project to test runtime logic.
When this setting is enabled, setup will skip typechecking and all assertions requiring type information will be skipped.
You likely want two scripts, one for running tests with types and one for tests without:
"test": "ATTEST_skipTypes=1 vitest run",
"testWithTypes": "vitest run",
Our recommendation is to use test
when:
- Only wanting to test runtime logic during development
- Running tests in watch mode or via VSCode's Test Explorer
Use testWithTypes
when:
- You've made changes to your types and want to recheck your type-level assertions
- You're running your tests in CI
typeToStringFormat
A set of prettier.Options
overrides that apply specifically type.toString
formatting.
Any options you provide will override the defaults, which are as follows:
{
"semi": false,
// note this print width is optimized for type serialization, not general code
"printWidth": 60,
"trailingComma": "none",
"parser": "typescript"
}
The easiest way to provide overrides is to the setup
function, but they can also be provided as a JSON serialized string either passed to a --typeToStringFormat
CLI flag or set as the value of ATTEST_typeToStringFormat
on process.env
.
Benches
Benches are run separately from tests and don't require any special setup. If the below file was benches.ts
, you could run it using something like tsx benches.ts
or ts-node benches.ts
:
type makeComplexType<s extends string> =
s extends `${infer head}${infer tail}` ? head | tail | makeComplexType<tail>
: s
bench("bench type", () => {
return {} as makeComplexType<"defenestration">
}).types([169, "instantiations"])
bench(
"bench runtime and type",
() => {
return {} as makeComplexType<"antidisestablishmentarianism">
},
fakeCallOptions
)
.mean([2, "ms"])
.types([337, "instantiations"])
If you're benchmarking an API, you'll need to include a "baseline expression" so that instantiations created when your API is initially invoked don't add noise to the individual tests.
Here's an example of what that looks like:
import { bench } from "@ark/attest"
import { type } from "arktype"
type("boolean")
bench("single-quoted", () => {
const _ = type("'nineteen characters'")
}).types([610, "instantiations"])
bench("keyword", () => {
const _ = type("string")
}).types([356, "instantiations"])
[!WARNING]
Be sure your baseline expression is not identical to an expression you are using in any of your benchmarks. If it is, the individual benchmarks will reuse its cached types, leading to reduced (or 0) instantiations.
If you'd like to fail in CI above a threshold, you can add flags like the following (default value is 20%, but it will not throw unless --benchErrorOnThresholdExceeded
is set):
tsx ./p99/within-limit/p99-tall-simple.bench.ts --benchErrorOnThresholdExceeded --benchPercentThreshold 10
CLI
Attest also includes a builtin attest
CLI including the following commands:
stats
npm run attest stats packages/*
Summarizes key type performance metrics for each package (check time, instantiations, and type count).
Expects any number of args representing package directories to check, optionally specified using glob patterns like packages/*
.
If no directories are provided, defaults to CWD.
trace
npm run attest trace .
Creates a trace.json file in .attest/trace
that can be viewed as a type performance heat map via a tool like https://ui.perfetto.dev/. Also summarizes any hot spots as identified by @typescript/analyze-trace
.
Trace expects a single argument representing the root directory of the root package for which to gather type information.
Integration
Setup
If you're a library author wanting to integrate type into your own assertions instead of using the attest
API, you'll need to call setup
with a list of attestAliases
to ensure type data is collected from your own functions:
setup({ attestAliases: ["yourCustomAssert"] })
You'll need to make sure that setup with whatever aliases you need before the first test runs. As part of the setup process, attest will search for the specified assertion calls and cache their types in a temporary file that will be referenced during test execution.
This ensures that type assertions can be made across processes without creating a new TSServer instance for each.
TS Versions
There is a tsVersions setting that allows testing multiple TypeScript aliases at once.
import { setup } from "@ark/attest"
setup({ tsVersions: "*" })
APIs
The most flexible attest APIs are getTypeAssertionsAtPosition
and caller
.
Here's an example of how you might use them in your own API:
import { getTypeAssertionsAtPosition, caller } from "@ark/attest"
const yourCustomAssert = <expectedType>(actualValue: expectedType) => {
const position = caller()
const types = getTypeAssertionsAtPosition(position)
const relationship = types[0].args[0].relationships.typeArgs[0]
if (relationship === undefined) {
throw new Error(
`yourCustomAssert requires a type arg representing the expected type, e.g. 'yourCustomAssert<"foo">("foo")'`
)
}
if (relationship !== "equality") {
throw new Error(
`Expected ${types.typeArgs[0].type}, got ${types.args[0].type} with relationship ${relationship}`
)
}
}
A user might then use yourCustomAssert
like this:
import { yourCustomAssert } from "your-package"
test("my code", () => {
yourCustomAssert<"foo">(`${"f"}oo` as const)
yourCustomAssert<boolean>(true)
yourCustomAssert<5>(2 + 3)
})
Please don't hesitate to a GitHub issue or discussion or reach out on ArkType's Discord if you have any questions or feedback- we'd love to hear from you! ⛵