Technical Documentation: graffle-js/graffle
โน๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference
Overview (`README.md`)
[!NOTE]
Looking forgraphql-request? It's been renamed to Graffle and the old version is available on thegraphql-requestbranch.
Graffle
Getting Started
Visit graffle.js.org/guides/getting-started for installation instructions, quick start guide, and examples.
Note: Graffle is currently in pre-release. Follow the documentation for the correct installation command.
Learn More
Visit graffle.js.org for full documentation, guides, and examples.
License
MIT
Sponsors
Overview (`tools/vitest-plugin-examples/README.md`)
Vitest Examples Plugin
A Vite plugin for testing executable documentation examples with type-safe configuration. This plugin automatically discovers example files, generates TypeScript types for type-safe encoder configuration, executes examples, captures their output, and integrates with Vitest's snapshot testing.
Features
- Automatic Type Generation: Generates TypeScript types in
@generated/test-examplesfor type-safe encoder configuration - Automatic Discovery: Finds example files using glob patterns
- Output Capture: Executes examples and captures stdout/stderr using Effect Platform
- Dynamic Value Masking: Type-safe encoder configuration for masking non-deterministic values
- Snapshot Testing: Integrates seamlessly with Vitest inline snapshots
- Lifecycle Hooks: Support for setup/teardown before/after each example
- Sequential Execution: Runs examples sequentially to avoid race conditions
- IDE Autocomplete: Full TypeScript autocomplete for example file paths
- Configurable Execution: Customize executor command, cwd, and environment
Installation
pnpm add -D @jasonkuhrt/vitest-plugin-examplesOr use it locally within the Graffle monorepo.
Usage
1. Configure Vitest to Use the Plugin
In your vitest.config.ts:
import { examplesPlugin } from '@jasonkuhrt/vitest-plugin-examples'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [
examplesPlugin({
pattern: './*/*.ts',
outputDir: './__outputs__',
}),
],
// ... rest of your config
})The plugin runs during Vite's buildStart lifecycle, generating types before TypeScript compilation.
2. Use the Convenience Test Function
In your test file:
import type { ExamplePath } from '@generated/test-examples'
import { createExamplesTest } from '@jasonkuhrt/vitest-plugin-examples'
import { test } from 'vitest'
// Type-safe encoder configuration with autocomplete
const encoders = {
'./examples/dynamic-timestamp.ts': (value: string) => {
return value.replace(/timestamp: \d+/g, 'timestamp: MASKED')
},
} satisfies Partial<Record<ExamplePath, EncoderFunction>>
createExamplesTest(test, {
timeout: 300000,
config: {
pattern: './*/*.ts',
outputDir: './__outputs__',
ignore: ['./$', './__outputs__', './__tests__'],
encoders,
beforeEach: async () => {
// Reset database before each example
await db.reset()
},
},
})That's it! The createExamplesTest() function:
- Automatically runs all discovered examples
- Creates a single Vitest test case
- Generates individual snapshots for each example
- Handles all assertions internally
Type-Safe Encoders
The plugin generates TypeScript types for all discovered example files in node_modules/@generated/test-examples/:
// Auto-generated - provides autocomplete for example paths
export interface ExampleFiles {
'./examples/example1.ts': true
'./examples/example2.ts': true
// ... all discovered examples
}
export type ExamplePath = keyof ExampleFilesUse these types for type-safe encoder configuration:
import type { EncoderFunction, ExamplePath } from '@generated/test-examples'
const encoders = {
'./examples/dynamic-values.ts': (output: string) => {
return output.replace(/'x-timestamp', '\d+'/gi, `'x-timestamp', 'MASKED'`)
},
// โ
TypeScript autocomplete works here!
// โ
Typos are caught at compile time!
} satisfies Partial<Record<ExamplePath, EncoderFunction>>Advanced: Manual Test Setup
If you need more control, you can use the lower-level APIs:
import { runExamples } from '@jasonkuhrt/vitest-plugin-examples'
import { expect, test } from 'vitest'
test('examples', async () => {
const results = await runExamples({
pattern: './*/*.ts',
outputDir: './__outputs__',
})
for (const result of results) {
expect(result.encoded).toMatchSnapshot(
`${result.file.group}/${result.file.name}`,
)
}
}, { timeout: 300000 })Configuration
`ExamplesPluginConfig`
/* Detailed source-code truncated for AI context efficiency. */`ExampleResult`
interface ExampleResult {
/** The example file that was executed */
file: ExampleFile
/** Captured stdout output */
stdout: string
/** Captured stderr output */
stderr: string
/** Whether the example failed */
failed: boolean
/** Exit code from the process */
exitCode: number | null
/** Encoded output (after applying encoders and masking) */
encoded: string
}How It Works
Type Generation (Vite plugin,
buildStarthook):- Discovers example files using Kit's
Fs.glob() - Generates TypeScript types to
node_modules/@generated/test-examples/ - Creates proper npm package with
package.jsonfor TypeScript module resolution - Runs before TypeScript compilation, ensuring types are available during type checking
- Discovers example files using Kit's
Test Execution (Vitest runtime):
- Discovers example files matching the pattern
- Runs each example using Effect Platform's
CommandAPI (configurable executor) - Captures stdout/stderr using Effect Streams
- Applies custom encoders + default masking for determinism
- Creates individual inline snapshots for each example
Default Encoding
The plugin applies default encoding to mask common dynamic values:
- File paths โ
/some/path/to/file.ts:XX:XX - Node.js version โ
Node.js vXX.XX.XX - Node internal module line numbers
- Dynamic error formatting differences
API
Plugin API
`examplesPlugin(config?)`
Vite plugin that generates TypeScript types for example files during build.
Usage: Add to vitest.config.ts plugins array
Configuration: Same as ExamplesPluginConfig
Test Helper API
`createExamplesTest(test, options?)`
Convenience function that creates a complete test case for all examples.
Parameters:
test- Vitest'stestfunctionoptions- Configuration object:config?: ExamplesPluginConfig- Plugin configurationtimeout?: number- Test timeout in milliseconds (default: 300000)testName?: string- Name of the test case (default: 'examples')
Returns: void (creates test internally)
`testExamples(config?)`
Creates a function that runs all examples with the given configuration.
Returns: () => Promise<ExampleResult[]>
`runExamples(config)`
Runs all examples and returns their results.
Returns: Promise<ExampleResult[]>
`runExample(relativePath, config)`
Runs a single example by its relative path.
Returns: Promise<ExampleResult>
`discoverExamples(config)`
Discovers example files without executing them.
Returns: Promise<ExampleFile[]>
`captureExample(file, config)`
Executes a single example file and captures its output.
Returns: Promise<ExampleResult>
Migration from Old System
The old system used:
scripts/generate-examples-derivatives/generate-outputs.ts- Manual output generation- Custom
runExample()andrunExampleForTest()functions - Separate snapshot path construction
- Manual encoder loading
The new plugin simplifies this to:
- Single
testExamples()call - Automatic snapshot integration
- Inline encoder configuration
- No manual output file generation needed
Performance
- Examples run sequentially (not in parallel) to prevent race conditions
- Typical run time: ~2-5 minutes for ~30 examples
- Set test timeout to at least 300000ms (5 minutes)
Dependencies
This plugin requires:
- Effect Platform (
@effect/platform,@effect/platform-node,effect) - For command execution and stream handling - @wollybeard/kit - For file system operations and FsLoc types
- Vite & Vitest (peer dependencies)
Limitations
- Executes examples via configurable executor (default:
tsx) - Sequential execution means slower than parallel (by design, for safety)
- Snapshot comparison happens in-memory (inline snapshots)
- Generated types live in
node_modules/@generated/- not committed to git - Requires Effect Platform and Kit dependencies
Future Improvements
- Support for parallel execution with opt-in
- Option to commit generated types to source control
- Comparison utilities for validating against old system
- Support for custom snapshot serializers
- Lighter-weight alternative without Effect/Kit dependencies
Guides Typescript Type Diagnostics (`docs/guides/typescript-type-diagnostics.md`)
TypeScript Type Diagnostics
Quick Check
Check specific file:
./scripts/check-file-types.sh examples/10_transport-http/transport-http_abort.tsCheck entire project:
pnpm ts:check:statsTools
1. `pnpm ts:check:stats` - Quick Stats
Shows instantiation counts and type checker time.
When to use: Quick sanity check for type performance.
Output:
Files: 500
Lines of Library: 200000
Instantiations: 150000
Time in Type Checker: 5.2sWhat it means:
- Instantiations = amount of type work. Higher = slower.
- Time in Type Checker = actual compilation time spent on types.
2. `pnpm ts:check:trace` - Generate Detailed Trace
Creates detailed trace of where TypeScript spends time.
When to use: Need to identify specific type hot spots.
Usage:
pnpm ts:check:trace
pnpm ts:analyzeOutput: Opens detailed breakdown showing which types are expensive.
3. `./scripts/check-file-types.sh` - Single File Check
Quick check for a specific file.
When to use: Debugging types in one file.
Example:
./scripts/check-file-types.sh src/client/methods/transport.tsInterpreting Results
Instantiation Counts
- < 1,000: Fast, no issues
- 1,000 - 10,000: Normal for complex types
- 10,000 - 50,000: Watch for performance issues
- > 50,000: Likely hitting performance problems
Common Problems
Large unions in constraints:
// SLOW: Quadratic comparison
type Process<T extends Type1 | Type2 | ... | Type50> = ...
// FAST: Structural constraint
type Process<T extends BaseType> = ...Intersection constraints:
// SLOW: Not cached
type Process<T extends Base & { x: X } & { y: Y }> = ...
// FAST: Cached
interface Constraint extends Base { x: X; y: Y }
type Process<T extends Constraint> = ...Deep conditional nesting:
- TypeScript evaluates conditionals eagerly
- Lift conditionals out or use mapped types
Workflow
Debugging Type Issues
Run quick check:
bash./scripts/check-file-types.sh path/to/file.tsIf instantiations > 10,000, generate trace:
bashpnpm ts:check:trace pnpm ts:analyzeIdentify hot spots in trace output
Apply fixes:
- Replace intersection constraints with interfaces
- Remove large union constraints
- Simplify conditional types
Verify improvement:
bash./scripts/check-file-types.sh path/to/file.ts
Before Committing
Run full check to catch regressions:
pnpm check:typesTools Used
tsc --extendedDiagnostics- Official TypeScript compiler statstsc --generateTrace- Official TypeScript trace generation@typescript/analyze-trace- Microsoft's trace analyzer
These are the state-of-the-art tools for TypeScript type performance analysis.
Guides Typescript Variance (`docs/guides/typescript-variance.md`)
TypeScript Variance Guide
Introduction
Variance describes how type parameter relationships (subtype/supertype) affect the assignability of generic types. Understanding variance is critical when designing type-safe APIs with phantom types.
This guide explains the four types of variance and provides practical examples using Graffle's variance helper types.
Quick Reference Table
| Variance | Helper | Assignment Direction | Example (1 and number) |
Use Case |
|---|---|---|---|---|
| Covariant | Covariant<T> |
Subtype โ Supertype โ | T<1> โ T<number> โ
T<number> โ T<1> โ |
Return values, readonly properties |
| Contravariant | Contravariant<T> |
Supertype โ Subtype โ | T<1> โ T<number> โT<number> โ T<1> โ
|
Function parameters |
| Invariant | Invariant<T> |
No assignments โ | T<1> โ T<number> โT<number> โ T<1> โ |
Mutable properties, exact matches |
| Bivariant | Bivariant<T> |
Both directions โ (unsafe) | T<1> โ T<number> โ
T<number> โ T<1> โ
|
Legacy DOM compatibility (avoid) |
Understanding with `1` and `number`
The literal type 1 and base type number clearly show variance direction:
// Type relationship
type IsSubtype = 1 extends number ? true : false // true - 1 is a subtype of numberCovariant Example
import type { Covariant } from 'graffle/prelude'
interface Container<T> {
readonly __type?: Covariant<T>
}
let narrow: Container<1> = {}
let wide: Container<number> = {}
// Natural direction: narrow โ wide
wide = narrow // โ
Works (1 extends number)
narrow = wide // โ Error: Type 'number' is not assignable to type '1'Why it works: A container that produces 1 can be used wherever a container that produces number is expected, because 1 is always a valid number.
Contravariant Example
import type { Contravariant } from 'graffle/prelude'
interface Handler<T> {
readonly __type?: Contravariant<T>
}
let narrow: Handler<1> = {}
let wide: Handler<number> = {}
// Reversed direction: wide โ narrow
narrow = wide // โ
Works (reversed!)
wide = narrow // โ ErrorWhy it works: A handler that can accept ANY number can substitute for a handler that accepts only 1, because it can handle the 1 case. But a handler that only accepts 1 cannot handle all numbers.
Invariant Example
import type { Invariant } from 'graffle/prelude'
interface Exact<T> {
readonly __type?: Invariant<T>
}
let one: Exact<1> = {}
let num: Exact<number> = {}
// No direction works
num = one // โ Error
one = num // โ ErrorWhy it's strict: Invariance is used for mutable containers where both reading AND writing occur. Neither direction is safe because:
- If
Exact<1>โExact<number>was allowed, you could write2into a container expecting only1 - If
Exact<number>โExact<1>was allowed, you could write1into a container expecting any number (this direction is actually safe, but TypeScript makes it invariant to prevent the other direction)
Bivariant Example (Unsafe!)
import type { Bivariant } from 'graffle/prelude'
interface Unsafe<T> {
readonly __type?: Bivariant<T>
}
let narrow: Unsafe<1> = {}
let wide: Unsafe<number> = {}
// Both directions work - DANGEROUS!
wide = narrow // โ
Works (normal covariance)
narrow = wide // โ
Works (UNSAFE!)Why it's unsafe: The second assignment allows number โ 1, which means you could later receive 2, 3, etc. where only 1 is expected, causing runtime errors.
How Variance is Determined
TypeScript determines variance based on where the type parameter appears:
Covariant Positions (Output)
- Function return types:
() => T - Readonly properties:
readonly prop: T - Promise results:
Promise<T>
Contravariant Positions (Input)
- Function parameters:
(param: T) => void - Setter parameters:
set value(v: T)
Invariant Positions (Both)
- Mutable properties:
value: T(can read AND write) - Function with parameter and return:
(param: T) => T
Bivariant Positions (Special Case)
- Method parameters (not function properties!):
method(param: T): void
Practical Example: Variable Builder
Graffle's variable builder uses covariance to allow specific default values to work with general variable types:
import { $var } from 'graffle'
// $var.default(1) returns Builder<1>
// This can be assigned to fields expecting Builder<number>
const query = Schema.query.users({
$: {
limit: $var.default(10), // Builder<10> โ Builder<number> โ
},
})
// But incompatible types are rejected
const badQuery = Schema.query.users({
$: {
limit: $var.default('ten'), // โ Builder<'ten'> โ Builder<number> ERROR
},
})Without the covariant phantom type (__type?: Covariant<$Type>), TypeScript would not enforce this relationship because all Builder<T> instances would be structurally identical regardless of T.
When to Use Each Variance
| Variance | Use When | Example |
|---|---|---|
| Covariant | Values flow out (producers) | Return types, readonly data, immutable containers |
| Contravariant | Values flow in (consumers) | Event handlers, callbacks, validators |
| Invariant | Values flow both ways (mutable) | Mutable arrays, read-write properties |
| Bivariant | Avoid! (legacy compatibility) | Only for specific DOM compatibility cases |
Implementation Details
The Phantom Type Trick
Phantom types add structural requirements without runtime overhead:
interface Builder<T> {
// Without phantom type - all Builder<T> are identical
readonly _state: SomeState
// With phantom type - Builder<T> depends on T
readonly __type?: Covariant<T> // โ Forces type checking
}The __type property:
- Is optional (
?) so it doesn't need runtime values - Uses a function signature to establish variance
- Never exists at runtime (phantom)
- Forces TypeScript to check
Tcompatibility
Why Function Signatures?
Function signatures make variance explicit:
// Covariant - return type
type Covariant<T> = () => T
// Contravariant - parameter type
type Contravariant<T> = (value: T) => void
// Invariant - both positions
type Invariant<T> = (value: T) => TThis is clearer than using properties directly and follows ecosystem conventions for phantom/branded types.
The Bivariant Hack - Indexed Access Question
The bivariant implementation uses indexed access to extract the method signature:
type Bivariant<T> = { bivariantHack(value: T): void }['bivariantHack']Why the indexed access?
The indexed access ['bivariantHack'] extracts the method type, converting it from:
- Method:
{ bivariantHack(value: T): void } - To function type:
(value: T) => void
Does this preserve bivariance?
Based on our tests, the extracted signature does preserve bivariant behavior when used as an optional property. The TypeScript compiler appears to remember the method origin and maintains the bivariant parameter checking.
However, note that:
- The optional
?modifier on the phantom property makes TypeScript somewhat permissive - In practice, the covariant phantom type works correctly for the Builder use case
- Contravariant and invariant helpers may be less reliable with optional properties in structural type checking
Practical Considerations
For real-world usage (like in Graffle's Builder):
- Covariant is reliable and works as expected โ
- Bivariant works for both directions as expected โ
- Contravariant and Invariant may behave unexpectedly with optional phantom properties
The helpers are most useful for:
- Documenting intent in type definitions
- Understanding variance relationships
- Teaching TypeScript variance concepts
References
- TypeScript Handbook: Type Compatibility
- TypeScript FAQ: Function Parameter Bivariance
- Variance in Programming Languages
See Also
--- METRICS ---
- Files Extracted: 4
- Estimated Token Budget: ~5868 tokens
- Canonical Reference: https://codewiki.google/github.com/graffle-js/graffle