Skip to content

Commit 40fcf18

Browse files
committed
Change types to base what is returned on tree
Previously, to define what `filter` returned could only be done through a TypeScript type parameter: ```js // This used to work but no longer! const result = filter<Heading>(tree, 'heading') expectType<Heading | null>(result) ``` This did not look at `tree` at all (even if `tree` was hast, and as `Heading` is mdast, it would yield `Heading | null`). It also made it impossible to narrow types in JS. Given that more and more of unist and friends is now strongly typed, we can expect `tree` to be some kind of implementation of `Node` rather than the abstract `Node` interface itself. This commit changes to perform the test (`'heading'`) in the type system and actually narrow down whether the inpu value matches `tree` or not. This gives us: ```js // This now works: const tree: Root = {/* … */} const result1 = filter(tree, () => Math.random() > 0.5) expectType<Root | null>(result1) const result2 = filter(tree, 'heading') expectType<null>(result2) const result3 = filter(tree, 'root') expectType<Root>(result2) ```
1 parent aa3bf1a commit 40fcf18

File tree

5 files changed

+125
-30
lines changed

5 files changed

+125
-30
lines changed

.gitignore

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
.DS_Store
2-
*.d.ts
3-
*.log
41
node_modules/
52
coverage/
3+
.DS_Store
4+
index.d.ts
5+
test.d.ts
6+
*.log

complex-types.d.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* eslint-disable @typescript-eslint/ban-types */
2+
3+
import type {Node, Parent} from 'unist'
4+
5+
type MatchesOne<Value, Check> =
6+
// Is this a node?
7+
Value extends Node
8+
? // No test.
9+
Check extends null
10+
? Value
11+
: // No test.
12+
Check extends undefined
13+
? Value
14+
: // Function test.
15+
Check extends Function
16+
? Check extends (value: any) => value is infer Inferred
17+
? Value extends Inferred
18+
? Value
19+
: null
20+
: // This test function isn’t a type predicate.
21+
Value | null
22+
: // String (type) test.
23+
Value['type'] extends Check
24+
? Value extends {type: Check}
25+
? Value
26+
: Value | null
27+
: // Partial test.
28+
Value extends Check
29+
? Value
30+
: null
31+
: null
32+
33+
export type Matches<Value, Check> =
34+
// Is this a list?
35+
Check extends any[]
36+
? MatchesOne<Value, Check[keyof Check]>
37+
: MatchesOne<Value, Check>
38+
39+
/* eslint-enable @typescript-eslint/ban-types */

index.js

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
import {convert} from 'unist-util-is'
2-
31
/**
42
* @typedef {import('unist').Node} Node
53
* @typedef {import('unist').Parent} Parent
6-
*
7-
* @typedef {import('unist-util-is').Type} Type
8-
* @typedef {import('unist-util-is').Props} Props
9-
* @typedef {import('unist-util-is').TestFunctionAnything} TestFunctionAnything
4+
* @typedef {import('unist-util-is').Test} Test
105
*/
116

127
/**
@@ -16,25 +11,32 @@ import {convert} from 'unist-util-is'
1611
* @property {boolean} [cascade=true] Whether to drop parent nodes if they had children, but all their children were filtered out.
1712
*/
1813

14+
import {convert} from 'unist-util-is'
15+
1916
const own = {}.hasOwnProperty
2017

18+
/**
19+
* Create a new tree consisting of copies of all nodes that pass test.
20+
* The tree is walked in preorder (NLR), visiting the node itself, then its head, etc.
21+
*
22+
* @param tree Tree to filter.
23+
* @param options Configuration (optional).
24+
* @param test is-compatible test (such as a type).
25+
* @returns Given `tree` or `null` if it didn’t pass `test`.
26+
*/
2127
export const filter =
2228
/**
2329
* @type {(
24-
* (<T extends Node>(node: Node, options: FilterOptions, test: T['type']|Partial<T>|import('unist-util-is').TestFunctionPredicate<T>|Array.<T['type']|Partial<T>|import('unist-util-is').TestFunctionPredicate<T>>) => T|null) &
25-
* (<T extends Node>(node: Node, test: T['type']|Partial<T>|import('unist-util-is').TestFunctionPredicate<T>|Array.<T['type']|Partial<T>|import('unist-util-is').TestFunctionPredicate<T>>) => T|null) &
26-
* ((node: Node, options: FilterOptions, test?: null|undefined|Type|Props|TestFunctionAnything|Array<Type|Props|TestFunctionAnything>) => Node|null) &
27-
* ((node: Node, test?: null|undefined|Type|Props|TestFunctionAnything|Array<Type|Props|TestFunctionAnything>) => Node|null)
30+
* (<Tree extends Node, Check extends Test>(node: Tree, options: FilterOptions, test: Check) => import('./complex-types').Matches<Tree, Check>) &
31+
* (<Tree extends Node, Check extends Test>(node: Tree, test: Check) => import('./complex-types').Matches<Tree, Check>) &
32+
* (<Tree extends Node>(node: Tree, options?: FilterOptions) => Tree)
2833
* )}
2934
*/
3035
(
3136
/**
32-
* Create a new tree consisting of copies of all nodes that pass test.
33-
* The tree is walked in preorder (NLR), visiting the node itself, then its head, etc.
34-
*
35-
* @param {Node} tree Tree to filter
37+
* @param {Node} tree
3638
* @param {FilterOptions} options
37-
* @param {null|undefined|Type|Props|TestFunctionAnything|Array<Type|Props|TestFunctionAnything>} test is-compatible test (such as a type)
39+
* @param {Test} test
3840
* @returns {Node|null}
3941
*/
4042
function (tree, options, test) {

index.test-d.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,59 @@
1-
import {Node} from 'unist'
21
import {expectType, expectError} from 'tsd'
3-
import {Heading} from 'mdast'
2+
import {Node} from 'unist'
3+
import {Root, Heading, Paragraph} from 'mdast'
44
import {filter} from './index.js'
55

6+
const root: Root = {type: 'root', children: []}
7+
/* eslint-disable @typescript-eslint/consistent-type-assertions */
8+
const justANode = {type: 'whatever'} as Node
9+
const headingOrParagraph = {
10+
type: 'paragraph',
11+
children: []
12+
} as Heading | Paragraph
13+
/* eslint-enable @typescript-eslint/consistent-type-assertions */
14+
615
expectError(filter())
7-
expectType<Node | null>(filter({type: 'root'}))
8-
expectType<Node | null>(filter({type: 'root'}, 'root'))
9-
expectType<Node | null>(filter({type: 'root'}, {}, 'root'))
10-
expectError(filter({type: 'root'}, {notAnOption: true}, 'root'))
11-
expectType<Node | null>(filter({type: 'root'}, {cascade: false}, 'root'))
12-
expectType<Heading | null>(filter<Heading>({type: 'root'}, 'heading'))
13-
expectError(filter<Heading>({type: 'root'}, 'notAHeading'))
16+
expectType<Root>(filter(root))
17+
expectType<Root>(filter(root, 'root'))
18+
expectType<Root>(filter(root, {}, 'root'))
19+
expectError(filter(root, {notAnOption: true}, 'root'))
20+
expectType<Root>(filter(root, {cascade: false}, 'root'))
21+
expectType<null>(filter(root, 'heading'))
22+
expectType<null>(filter(root, {cascade: false}, 'notAHeading'))
23+
24+
// Vague types.
25+
expectType<Heading | Paragraph>(filter(headingOrParagraph))
26+
expectType<Paragraph | null>(filter(headingOrParagraph, 'paragraph'))
27+
expectType<null>(filter(headingOrParagraph, 'notAHeading'))
28+
expectType<Heading | null>(
29+
filter(headingOrParagraph, {cascade: false}, 'heading')
30+
)
31+
32+
expectType<Heading | Paragraph | null>(
33+
filter(headingOrParagraph, {cascade: false}, () => Math.random() > 0.5)
34+
)
35+
36+
expectType<Heading | null>(
37+
filter(
38+
headingOrParagraph,
39+
{cascade: false},
40+
(node: Node): node is Heading => node.type === 'heading'
41+
)
42+
)
43+
44+
// Abstract types.
45+
// These don’t work well.
46+
// Use strict nodes types.
47+
expectType<Node>(filter(justANode))
48+
expectType<null>(filter(justANode, '???'))
49+
expectType<null>(filter(justANode, {cascade: false}, '???'))
50+
expectType<Node | null>(
51+
filter(justANode, {cascade: false}, () => Math.random() > 0.5)
52+
)
53+
expectType<null>(
54+
filter(
55+
justANode,
56+
{cascade: false},
57+
(node: Node): node is Heading => node.type === 'heading'
58+
)
59+
)

package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@
2929
"main": "index.js",
3030
"types": "index.d.ts",
3131
"files": [
32+
"complex-types.d.ts",
3233
"index.d.ts",
3334
"index.js"
3435
],
3536
"dependencies": {
3637
"@types/unist": "^2.0.0",
37-
"unist-util-is": "^5.0.0"
38+
"unist-util-is": "^5.0.0",
39+
"unist-util-visit-parents": "^5.0.0"
3840
},
3941
"devDependencies": {
4042
"@types/mdast": "^3.0.0",
@@ -53,7 +55,7 @@
5355
},
5456
"scripts": {
5557
"prepack": "npm run build && npm run format",
56-
"build": "rimraf \"*.d.ts\" && tsc && tsd && type-coverage",
58+
"build": "rimraf \"{index,test}.d.ts\" && tsc && tsd && type-coverage",
5759
"format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix",
5860
"test-api": "node test.js",
5961
"test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov node test.js",
@@ -78,6 +80,11 @@
7880
"typeCoverage": {
7981
"atLeast": 100,
8082
"detail": true,
81-
"strict": true
83+
"strict": true,
84+
"ignoreCatch": true,
85+
"#": "needed `any`s",
86+
"ignoreFiles": [
87+
"complex-types.d.ts"
88+
]
8289
}
8390
}

0 commit comments

Comments
 (0)