Skip to content

Conversation

@som-sm
Copy link
Collaborator

@som-sm som-sm commented Oct 10, 2025

This PR introduces a lint rule that extracts example code blocks from JSDoc comments and validates them using @typescript/vfs. It then reports any errors at their corresponding locations.

The errors and their locations are identical to normal TS errors, so the experience feels just like editing a regular TS file.


image image
Screen.Recording.2025-10-10.at.6.10.33.PM.mov

Working

Linting a single file involves the following steps:

  1. Setting up the environment
    A virtual TypeScript environment with a single virtual file is created on top of the existing file system. And the virtual environment is created such that it has access to the files on disk.

    Location of virtual files
    While setting up the virtual environment, we specify the root directory. The virtual environment then creates a virtual directory called vfs within the root directory, and all virtual files are created relative to this vfs directory.

    For example, the following snippet:

    // type-fest/lint-rules/validate-jsdoc-codeblocks.js
    import ts from "typescript";
    import {createFSBackedSystem, createVirtualTypeScriptEnvironment} from "@typescript/vfs";
    
    const FILENAME = 'example-codeblock.ts';
    
    const compilerOptions = {...};
    
    const virtualFsMap = new Map();
    virtualFsMap.set(FILENAME, 'Some code here...');
    
    const rootDir = path.join(import.meta.dirname, '..'); // We go back one level from `lint-rules` dir
    const system = createFSBackedSystem(virtualFsMap, rootDir, ts);
    const env = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, compilerOptions);

    creates the following directory structure:

    type-fest/
    ├── package.json
    ├── source/
    │   ├── all-extend.d.ts
    │   ├── ...
    │   └── xor.d.ts
    └── vfs/ (virtual)
        └── example-codeblock.ts  (virtual file)
    

    So, if we want to import the Or type from our example-codeblock.ts virtual file, we'd have to use '../source/or.d.ts' as the import path, like:

    import type {Or} from '../source/or.d.ts';
    
    const tt: Or<true, true> = true;
    const tf: Or<true, false> = true;
    const ft: Or<false, true> = true;
    const ff: Or<false, false> = false;

    The location of the virtual file might seem problematic, but in practice it’s not, because our JSDoc example codeblocks never use relative imports. Instead, they always import from 'type-fest':

    import type {Or} from 'type-fest';
    
    const tt: Or<true, true> = true;
    const tf: Or<true, false> = true;
    const ft: Or<false, true> = true;
    const ff: Or<false, false> = false;

    Note: Importing directly from 'type-fest' works fine because Node.js allows self referencing a package using its name.

  2. Running the lint rule on each exported type node

    • The rule extracts the JSDoc comment corresponding to that node and then extracts the codeblocks within the JSDoc.
    • It iterates over the codeblocks, replaces the contents of the virtual file with the codeblock, and runs TypeScript diagnostics on the virtual file.
    • Finally, it collects any diagnostic errors and reports them back at their exact locations.

The lint rule surfaced around ~250 errors (on publicly accessible docs), so this PR also fixes/improves multiple JSDoc code examples. Some common errors that it catches:

  • Missing import statements
    image

  • Floating examples
    image

  • References to hypothetical functions/variables
    image

  • Duplicate identifiers
    image

  • Syntax errors
    image

  • Typos
    image
    image

@claude

This comment was marked as outdated.

@som-sm som-sm force-pushed the feat/add-lint-rule-to-validate-jsdoc-codeblocks branch from c641bf8 to 89b14c8 Compare October 10, 2025 13:27
@claude

This comment was marked as outdated.

@som-sm
Copy link
Collaborator Author

som-sm commented Oct 10, 2025

@sindresorhus This PR is still a WIP, because there are still a lot of code blocks that have errors in them, but would appreciate if you could give an early feedback on this.

Couple of questions:

  • At several places we have code examples for internal types, like:

    /**
    Returns true if `LongString` is made up out of `Substring` repeated 0 or more times.
    @example
    ```
    ConsistsOnlyOf<'aaa', 'a'> //=> true
    ConsistsOnlyOf<'ababab', 'ab'> //=> true
    ConsistsOnlyOf<'aBa', 'a'> //=> false
    ConsistsOnlyOf<'', 'a'> //=> true
    ```
    */
    type ConsistsOnlyOf<LongString extends string, Substring extends string> =

    Now, in such cases, we can't have an import statement because internal types are not publicly available.

    So, we can either make this rule work only for exported types, or inline the examples like I've done in this change:
    image

    Or, maybe we can do something even better, wdyt?

  • At times we highlight errors in our codeblocks, but with this lint rule in place, we can't have errors in codeblocks, so I guess it's fine to add an @ts-expect-error directive in such cases. This would silence the error and also clearly state the intent that an error is indeed expected. Refer this change:

    github com_sindresorhus_type-fest_pull_1265_files (1)
  • I think this rule makes sense and is useful, but unfortunately it takes around min and a half to run, which might be a problem. Although, the in editor DX is pretty fast, it's just when it runs on all files together.

    Screen.Recording.2025-10-10.at.6.10.33.PM.mov

    Performance Consideration: Line 68 creates a new virtual TypeScript environment for each code block. For files with many examples, this could be slow. Consider reusing the environment or batching:

    // Current approach creates new env per codeblock
    const env = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, compilerOptions);

    Suggestion: Create one environment per file and update it with new content, rather than recreating for each block.

    This suggestion isn't possible because you can't update the virtual file once the virtual environment is created.

    I'm looking into ways for improving this, but let me know if you have some thoughts around this.

@claude

This comment was marked as outdated.

@claude

This comment was marked as outdated.

@claude

This comment was marked as outdated.

@claude

This comment was marked as outdated.

@som-sm
Copy link
Collaborator Author

som-sm commented Oct 10, 2025

  • but unfortunately it takes around min and a half to run, which might be a problem.

Should be fairly fast now, the env creation happens only once now!

Now, the only task left is to fix the remaining 214 errors 😪

@som-sm som-sm force-pushed the feat/add-lint-rule-to-validate-jsdoc-codeblocks branch from 186f9e2 to d974efa Compare October 10, 2025 18:23
@som-sm
Copy link
Collaborator Author

som-sm commented Oct 10, 2025

the env creation happens only once now!

No, it can't run just once.


  • but unfortunately it takes around min and a half to run, which might be a problem.

The env creation now happens per file instead of the initial per file per code block setup. So, it now takes around a min. Refer #d974efa.

@sindresorhus
Copy link
Owner

This is a very useful rule 🙌

@sindresorhus
Copy link
Owner

Now, in such cases, we can't have an import statement because internal types are not publicly available.

I think we should just ignore the internal types. Many will eventually be exposed and then they can be fixed then.

At times we highlight errors in our codeblocks, but with this lint rule in place, we can't have errors in codeblocks, so I guess it's fine to add an @ts-expect-error directive in such cases. This would silence the error and also clearly state the intent that an error is indeed expected. Refer this change:

👍

I think this rule makes sense and is useful, but unfortunately it takes around min and a half to run, which might be a problem. Although, the in editor DX is pretty fast, it's just when it runs on all files together.

Doesn't ESLint cache things? Maybe something is not working with the cache.

Alternatively, we could only run this rule in CI.

Repository owner deleted a comment from claude bot Nov 4, 2025
Repository owner deleted a comment from claude bot Nov 4, 2025
Repository owner deleted a comment from claude bot Nov 4, 2025
Repository owner deleted a comment from claude bot Nov 6, 2025
Repository owner deleted a comment from claude bot Nov 6, 2025
@som-sm som-sm force-pushed the feat/add-lint-rule-to-validate-jsdoc-codeblocks branch 2 times, most recently from 7a0bde1 to d000858 Compare November 7, 2025 10:24
Repository owner deleted a comment from claude bot Nov 7, 2025
Copy link
Collaborator Author

@som-sm som-sm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added comments wherever I felt it needed some explanation. Hope it makes the review process a tad bit easier.

Comment on lines -22 to -24
// TypeScript now knows that `someArg` is `SomeType` automatically.
// It also knows that this function must return `Promise<Foo>`.
// If you have `@typescript-eslint/promise-function-async` linter rule enabled, it will even report that "Functions that return promises must be async.".
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this example because these three points have nothing to do with the type.

Comment on lines -17 to -18
IsNever<Exclude<A, B>> extends true ? true : false,
IsNever<Exclude<B, A>> extends true ? true : false
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this example because the primary focus here wasn't on IsNever. Added a better example (see below).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced this example because it had multiple issues, but the updated example conveys a similar idea as the original example.

Comment on lines -37 to -41
type Union = typeof union;
//=> {a1(): void; b1(): void} | {a2(argA: string): void; b2(argB: string): void}
type Intersection = UnionToIntersection<Union>;
//=> {a1(): void; b1(): void; a2(argA: string): void; b2(argB: string): void}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example is almost similar to the previous one for this type, so simply removed it instead of replacing it with an alternative.

onlyBar('bar');
//=> 2
type C = ValueOf<{id: number; name: string; active: boolean}, 'id' | 'name'>;
//=> number | string
Copy link
Collaborator Author

@som-sm som-sm Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing example had issues, and a simpler example seemed better in this case.

// type A = string;
// ```
// @category Test
// *​/
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a zero-width space character b/w * and / to prevent */ from being interpreted as the end of the JSDoc comment. Because of which there's a /* eslint-disable no-irregular-whitespace */ comment at the start of this file.

Umm...not sure if there's a better way of achieving this, I tried a couple different solutions but nothing seemed to work.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of which there's a /* eslint-disable no-irregular-whitespace */ comment at the start of this file.

Now, the rule is disabled only for comments.

type-fest/xo.config.js

Lines 88 to 98 in 657dd4d

{
files: 'lint-rules/test-utils.js',
rules: {
'no-irregular-whitespace': [
'error',
{
'skipComments': true,
},
],
},
},

Comment on lines +90 to +119
exportTypeAndOption(''),
exportType('type Some = number;'),
exportTypeAndOption('// Not block comment'),
exportTypeAndOption('/* Block comment, but not JSDoc */'),

// No codeblock in JSDoc
exportType(jsdoc('No codeblock here')),

// With text before and after
exportTypeAndOption(jsdoc('Some description.', fence(code1), '@category Test')),

// With line breaks before and after
exportTypeAndOption(
jsdoc('Some description.\n', 'Note: Some note.\n', fence(code1, 'ts'), '\n@category Test'),
),

// With `@example` tag
exportTypeAndOption(jsdoc('@example', fence(code1))),

// With language specifiers
exportTypeAndOption(jsdoc(fence(code1, 'ts'))),
exportTypeAndOption(jsdoc(fence(code1, 'typescript'))),

// Multiple code blocks
exportTypeAndOption(
jsdoc('@example', fence(code1, 'ts'), '\nSome text in between.\n', '@example', fence(code2)),
),

// Multiple exports and multiple properties
exportTypeAndOption(jsdoc(fence(code1)), jsdoc(fence(code2))),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These utils help make the test cases terse, otherwise this already lengthy file would have been even lengthier.

For example, the following

exportTypeAndOption(jsdoc(fence(code1)), jsdoc(fence(code2)))

evaluates to:

/**
```
type A = string;
```
*/
export type T0 = string;

/**
```
type B = number;
```
*/
export type T1 = string;

export type TOptions = {
        /**
        ```
        type A = string;
        ```
        */
        p0: string;

        /**
        ```
        type B = number;
        ```
        */
        p1: string;
};

*/
p0: string;
};
`,
Copy link
Collaborator Author

@som-sm som-sm Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For invalid tests, the code is not generated via some utility, as it is for valid cases, because here we want to validate the exact line and column numbers where the errors appear, and if the code were obfuscated behind some utility, then those numbers would feel magical/arbitrary, reducing the overall readability and maintainability of the test cases.

Comment on lines +171 to +172
errorAt({line: 4, textBeforeStart: 'type A = ', target: 'Subtract'}),
errorAt({line: 14, textBeforeStart: '\ttype A = ', target: 'Subtract'}),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For errors, we use a utility because manually typing the column and endColumn values is error-prone and also less readable.

For example, in the following test

{
	code: dedenter`
		/**
		@example
		\`\`\`ts
		type NoImport = Add<1, 2>;
		\`\`\`
		*/
		export type Add = number;
	`,
	errors: [
		{messageId: 'invalidCodeblock', line: 4, column: 17, endLine: 4, endColumn: 20},
	],
},

the numbers 17 and 20 feel magical/arbitrary and it's very easy to mess up those numbers and end up with false-positive tests.

It's much more readable if the column and endColumn values are generated like this:

errors: [
	{
		messageId: 'invalidCodeblock',
		line: 4,
		column: 'type NoImport = '.length + 1,
		endLine: 4,
		endColumn: 'type NoImport = '.length + 1 + 'Add'.length,
	},
],

But, this is still repetitive and has scope for simplification.

And, that's what the errorAt utility simplifies:

errors: [
	errorAt({
		line: 4,
		textBeforeStart: 'type NoImport = ',
		target: 'Add',
	}),
],

Note: The line and endLine values are manually entered because those are pretty straightforward. And errorAt uses the line value if an endLine is not explicitly passed, which also reduces the repetition a bit.

Repository owner deleted a comment from claude bot Nov 10, 2025
Repository owner deleted a comment from claude bot Nov 10, 2025
```
import type {IsUnknown} from 'type-fest';
// https://github.com/pajecawav/tiny-global-store/blob/master/src/index.ts
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing example had several issues, and it wasn’t really instantiating IsUnknown with unknown. Replaced it with a simpler example.

Repository owner deleted a comment from claude bot Nov 10, 2025
@som-sm som-sm marked this pull request as ready for review November 10, 2025 15:38
@som-sm
Copy link
Collaborator Author

som-sm commented Nov 10, 2025

@sindresorhus This PR is finally ready for review. It took some effort to get it to the finish line, but I’m happy with how it turned out. I’ve also updated the PR description to include a detailed explanation of how this lint rule works.

The only changes within the source directory are inside JSDoc example code blocks. I’ve fixed those examples using the following approach:

  1. If an example was easily fixable (e.g., missing import, typo, or floating snippet), I simply made the fix without checking the logic or relevance of the example.

  2. If an example contained errors that weren’t easily fixable, I improved it from the ground up. I’ve added comments wherever I made such changes.

@som-sm som-sm requested a review from sindresorhus November 10, 2025 15:51
@som-sm
Copy link
Collaborator Author

som-sm commented Nov 10, 2025

  • At times we highlight errors in our codeblocks, but with this lint rule in place, we can't have errors in codeblocks, so I guess it's fine to add an @ts-expect-error directive in such cases. This would silence the error and also clearly state the intent that an error is indeed expected. Refer this change:

NOTE: Using the @ symbol (@ts-expect-error) inside JSDoc codeblocks breaks rendering. There's already an open issue for the same.

image

Repository owner deleted a comment from claude bot Nov 10, 2025
@som-sm som-sm force-pushed the feat/add-lint-rule-to-validate-jsdoc-codeblocks branch from 657dd4d to b3b5b9c Compare November 10, 2025 21:14
Repository owner deleted a comment from claude bot Nov 10, 2025
@sindresorhus sindresorhus merged commit 2300245 into main Nov 12, 2025
6 of 7 checks passed
@sindresorhus sindresorhus deleted the feat/add-lint-rule-to-validate-jsdoc-codeblocks branch November 12, 2025 04:52
@sindresorhus
Copy link
Owner

This is super nice! 🎉 And seeing all the changes to code examples, it was really needed. It's hard to manually ensure code examples are correct.

Repository owner deleted a comment from claude bot Nov 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants