Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions lint-rules/validate-jsdoc-codeblocks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import path from 'node:path';
import ts from 'typescript';
import {createFSBackedSystem, createVirtualTypeScriptEnvironment} from '@typescript/vfs';

const CODEBLOCK_REGEX = /(?<openingFence>```(?:ts|typescript)?\n)(?<code>[\s\S]*?)```/g;
const FILENAME = 'example-codeblock.ts';

const compilerOptions = {
lib: ['lib.es2023.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.Node20,
moduleResolution: ts.ModuleResolutionKind.Node16,
strict: true,
noImplicitReturns: true,
noImplicitOverride: true,
noUnusedLocals: false, // This is intentionally disabled
noUnusedParameters: true,
noFallthroughCasesInSwitch: true,
noUncheckedIndexedAccess: true,
noPropertyAccessFromIndexSignature: true,
noUncheckedSideEffectImports: true,
useDefineForClassFields: true,
exactOptionalPropertyTypes: true,
};

const virtualFsMap = new Map();
virtualFsMap.set(FILENAME, '// Can\'t be empty');

const rootDir = path.join(import.meta.dirname, '..');
const system = createFSBackedSystem(virtualFsMap, rootDir, ts);
const env = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, compilerOptions);

export const validateJSDocCodeblocksRule = /** @type {const} */ ({
meta: {
type: 'suggestion',
docs: {
description: 'Ensures JSDoc example codeblocks don\'t have errors',
},
messages: {
error: '{{message}}',
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
TSTypeAliasDeclaration(node) {
const filename = context.filename.replaceAll('\\', '/');

// Skip internal files
if (filename.includes('/internal/')) {
return {};
}

const {parent} = node;

// Skip if type is not exported or starts with an underscore (private/internal)
if (parent.type !== 'ExportNamedDeclaration' || node.id.name.startsWith('_')) {
return;
}

const previousNodes = [context.sourceCode.getTokenBefore(parent, {includeComments: true})];

// Handle JSDoc blocks for options
if (node.id.name.endsWith('Options') && node.typeAnnotation.type === 'TSTypeLiteral') {
for (const member of node.typeAnnotation.members) {
previousNodes.push(context.sourceCode.getTokenBefore(member, {includeComments: true}));
}
}

for (const previousNode of previousNodes) {
// Skip if previous node is not a JSDoc comment
if (!previousNode || previousNode.type !== 'Block' || !previousNode.value.startsWith('*')) {
continue;
}

const comment = previousNode.value;

for (const match of comment.matchAll(CODEBLOCK_REGEX)) {
const {code, openingFence} = match.groups ?? {};

// Skip empty code blocks
if (!code || !openingFence) {
continue;
}

const matchOffset = match.index + openingFence.length + 2; // Add `2` because `comment` doesn't include the starting `/*`
const codeStartIndex = previousNode.range[0] + matchOffset;

env.updateFile(FILENAME, code);
const syntacticDiagnostics = env.languageService.getSyntacticDiagnostics(FILENAME);
const semanticDiagnostics = env.languageService.getSemanticDiagnostics(FILENAME);
const diagnostics = syntacticDiagnostics.length > 0 ? syntacticDiagnostics : semanticDiagnostics; // Show semantic errors only if there are no syntactic errors

for (const diagnostic of diagnostics) {
// If diagnostic location is not available, report on the entire code block
const diagnosticStart = codeStartIndex + (diagnostic.start ?? 0);
const diagnosticEnd = diagnosticStart + (diagnostic.length ?? code.length);

context.report({
loc: {
start: context.sourceCode.getLocFromIndex(diagnosticStart),
end: context.sourceCode.getLocFromIndex(diagnosticEnd),
},
messageId: 'error',
data: {
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
},
});
}
}
}
},
};
},
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@sindresorhus/tsconfig": "^8.0.1",
"@typescript-eslint/parser": "^8.44.0",
"eslint": "^9.35.0",
"@typescript/vfs": "^1.6.1",
"expect-type": "^1.2.2",
"npm-run-all2": "^8.0.4",
"tsd": "^0.33.0",
Expand Down
3 changes: 2 additions & 1 deletion source/all-union-fields.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ function displayPetInfo(petInfo: Cat | Dog) {
console.log('type: ', petInfo.type);

// TypeScript complains about `catType` and `dogType` not existing on type `Cat | Dog`.
// @ts-expect-error
console.log('animal type: ', petInfo.catType ?? petInfo.dogType);
}

function displayPetInfo(petInfo: AllUnionFields<Cat | Dog>) {
function displayPetInfoWithAllUnionFields(petInfo: AllUnionFields<Cat | Dog>) {
// typeof petInfo =>
// {
// name: string;
Expand Down
24 changes: 9 additions & 15 deletions source/array-splice.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,7 @@ SplitIndex extends 0

/**
Split the given array `T` by the given `SplitIndex`.

@example
```
type A = SplitArrayByIndex<[1, 2, 3, 4], 2>;
// type A = [[1, 2], [3, 4]];

type B = SplitArrayByIndex<[1, 2, 3, 4], 0>;
// type B = [[], [1, 2, 3, 4]];
```
For example, `SplitArrayByIndex<[1, 2, 3, 4], 2>` results in `[[1, 2], [3, 4]]` and `SplitArrayByIndex<[1, 2, 3, 4], 0>` results in `[[], [1, 2, 3, 4]]`.
*/
type SplitArrayByIndex<T extends UnknownArray, SplitIndex extends number> =
SplitIndex extends 0
Expand All @@ -72,17 +64,19 @@ Like [`Array#splice()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/

@example
```
import type {ArraySplice} from 'type-fest';

type SomeMonths0 = ['January', 'April', 'June'];
type Mouths0 = ArraySplice<SomeMonths0, 1, 0, ['Feb', 'March']>;
//=> type Mouths0 = ['January', 'Feb', 'March', 'April', 'June'];
type Months0 = ArraySplice<SomeMonths0, 1, 0, ['Feb', 'March']>;
//=> ['January', 'Feb', 'March', 'April', 'June'];

type SomeMonths1 = ['January', 'April', 'June'];
type Mouths1 = ArraySplice<SomeMonths1, 1, 1>;
//=> type Mouths1 = ['January', 'June'];
type Months1 = ArraySplice<SomeMonths1, 1, 1>;
//=> ['January', 'June'];

type SomeMonths2 = ['January', 'Foo', 'April'];
type Mouths2 = ArraySplice<SomeMonths2, 1, 1, ['Feb', 'March']>;
//=> type Mouths2 = ['January', 'Feb', 'March', 'April'];
type Months2 = ArraySplice<SomeMonths2, 1, 1, ['Feb', 'March']>;
//=> ['January', 'Feb', 'March', 'April'];
```

@category Array
Expand Down
2 changes: 1 addition & 1 deletion source/arrayable.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function bundle(input: string, output: Arrayable<string>) {
// …

for (const output of outputList) {
console.log(`write to: ${output}`);
console.log(`write ${input} to: ${output}`);
}
}

Expand Down
6 changes: 4 additions & 2 deletions source/async-return-type.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ There has been [discussion](https://github.com/microsoft/TypeScript/pull/35998)
@example
```ts
import type {AsyncReturnType} from 'type-fest';
import {asyncFunction} from 'api';

declare function asyncFunction(): Promise<{foo: string}>;

// This type resolves to the unwrapped return type of `asyncFunction`.
type Value = AsyncReturnType<typeof asyncFunction>;
//=> {foo: string}

async function doSomething(value: Value) {}
declare function doSomething(value: Value): void;

asyncFunction().then(value => doSomething(value));
```
Expand Down
23 changes: 7 additions & 16 deletions source/asyncify.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,13 @@ Use-case: You have two functions, one synchronous and one asynchronous that do t
```
import type {Asyncify} from 'type-fest';

// Synchronous function.
function getFooSync(someArg: SomeType): Foo {
// …
}

type AsyncifiedFooGetter = Asyncify<typeof getFooSync>;
//=> type AsyncifiedFooGetter = (someArg: SomeType) => Promise<Foo>;

// Same as `getFooSync` but asynchronous.
const getFooAsync: AsyncifiedFooGetter = (someArg) => {
// 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.".

// …
}
// Synchronous function
type Config = {featureFlags: Record<string, boolean>};

declare function loadConfigSync(path: string): Config;

type LoadConfigAsync = Asyncify<typeof loadConfigSync>;
//=> (path: string) => Promise<Config>
```

@category Async
Expand Down
5 changes: 5 additions & 0 deletions source/characters.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ Matches any uppercase letter in the basic Latin alphabet (A-Z).
import type {UppercaseLetter} from 'type-fest';

const a: UppercaseLetter = 'A'; // Valid
// @ts-expect-error
const b: UppercaseLetter = 'a'; // Invalid
// @ts-expect-error
const c: UppercaseLetter = 'AB'; // Invalid
```

Expand All @@ -22,6 +24,7 @@ Matches any lowercase letter in the basic Latin alphabet (a-z).
import type {LowercaseLetter} from 'type-fest';

const a: LowercaseLetter = 'a'; // Valid
// @ts-expect-error
const b: LowercaseLetter = 'A'; // Invalid
```

Expand All @@ -37,6 +40,7 @@ Matches any digit as a string ('0'-'9').
import type {DigitCharacter} from 'type-fest';

const a: DigitCharacter = '0'; // Valid
// @ts-expect-error
const b: DigitCharacter = 0; // Invalid
```

Expand All @@ -52,6 +56,7 @@ Matches any lowercase letter (a-z), uppercase letter (A-Z), or digit ('0'-'9') i
import type {Alphanumeric} from 'type-fest';

const a: Alphanumeric = 'A'; // Valid
// @ts-expect-error
const b: Alphanumeric = '#'; // Invalid
```

Expand Down
4 changes: 1 addition & 3 deletions source/conditional-except.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ This is useful when you want to create a new type with a specific set of keys fr
import type {Primitive, ConditionalExcept} from 'type-fest';

class Awesome {
name: string;
successes: number;
failures: bigint;
constructor(public name: string, public successes: number, public failures: bigint) {}

run() {}
}
Expand Down
4 changes: 1 addition & 3 deletions source/conditional-pick.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ This is useful when you want to create a new type from a specific subset of an e
import type {Primitive, ConditionalPick} from 'type-fest';

class Awesome {
name: string;
successes: number;
failures: bigint;
constructor(public name: string, public successes: number, public failures: bigint) {}

run() {}
}
Expand Down
13 changes: 9 additions & 4 deletions source/distributed-omit.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,25 @@ type Union = A | B;
type OmittedUnion = Omit<Union, 'foo'>;
//=> {discriminant: 'A' | 'B'}

const omittedUnion: OmittedUnion = createOmittedUnion();
declare const omittedUnion: OmittedUnion;

if (omittedUnion.discriminant === 'A') {
// We would like to narrow `omittedUnion`'s type
// to `A` here, but we can't because `Omit`
// doesn't distribute over unions.

// @ts-expect-error
omittedUnion.a;
//=> Error: `a` is not a property of `{discriminant: 'A' | 'B'}`
//=> Error: `a` is not a property of `{discriminant: 'A' | 'B'}`
}
```

While `Except` solves this problem, it restricts the keys you can omit to the ones that are present in **ALL** union members, where `DistributedOmit` allows you to omit keys that are present in **ANY** union member.

@example
```
import type {DistributedOmit} from 'type-fest';

type A = {
discriminant: 'A';
foo: string;
Expand All @@ -67,15 +70,17 @@ type Union = A | B | C;

type OmittedUnion = DistributedOmit<Union, 'foo' | 'bar'>;

const omittedUnion: OmittedUnion = createOmittedUnion();
declare const omittedUnion: OmittedUnion;

if (omittedUnion.discriminant === 'A') {
omittedUnion.a;
//=> OK

// @ts-expect-error
omittedUnion.foo;
//=> Error: `foo` is not a property of `{discriminant: 'A'; a: string}`
//=> Error: `foo` is not a property of `{discriminant: 'A'; a: string}`

// @ts-expect-error
omittedUnion.bar;
//=> Error: `bar` is not a property of `{discriminant: 'A'; a: string}`
}
Expand Down
9 changes: 7 additions & 2 deletions source/distributed-pick.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,23 @@ type Union = A | B;
type PickedUnion = Pick<Union, 'discriminant' | 'foo'>;
//=> {discriminant: 'A' | 'B', foo: {bar: string} | {baz: string}}

const pickedUnion: PickedUnion = createPickedUnion();
declare const pickedUnion: PickedUnion;

if (pickedUnion.discriminant === 'A') {
// We would like to narrow `pickedUnion`'s type
// to `A` here, but we can't because `Pick`
// doesn't distribute over unions.

// @ts-expect-error
pickedUnion.foo.bar;
//=> Error: Property 'bar' does not exist on type '{bar: string} | {baz: string}'.
}
```

@example
```
import type {DistributedPick} from 'type-fest';

type A = {
discriminant: 'A';
foo: {
Expand All @@ -63,15 +66,17 @@ type Union = A | B;

type PickedUnion = DistributedPick<Union, 'discriminant' | 'foo'>;

const pickedUnion: PickedUnion = createPickedUnion();
declare const pickedUnion: PickedUnion;

if (pickedUnion.discriminant === 'A') {
pickedUnion.foo.bar;
//=> OK

// @ts-expect-error
pickedUnion.extraneous;
//=> Error: Property `extraneous` does not exist on type `Pick<A, 'discriminant' | 'foo'>`.

// @ts-expect-error
pickedUnion.foo.baz;
//=> Error: `bar` is not a property of `{discriminant: 'A'; a: string}`.
}
Expand Down
Loading