Skip to content

Commit c724554

Browse files
authored
🤖 Merge PR DefinitelyTyped#73197 [d3-dispatch] Implement type-safe events by @k-yle
1 parent 7c02b58 commit c724554

File tree

3 files changed

+261
-14
lines changed

3 files changed

+261
-14
lines changed

types/d3-dispatch/d3-dispatch-tests.ts

Lines changed: 210 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,33 @@
88

99
import * as d3Dispatch from "d3-dispatch";
1010

11+
// Utils --------------------------------------------
12+
13+
let extractTests: [
14+
// $ExpectType 'a'
15+
d3Dispatch.Dispatch.ExtractEventNames<"a">,
16+
// $ExpectType 'a'
17+
d3Dispatch.Dispatch.ExtractEventNames<"a.1">,
18+
// $ExpectType 'a' | 'b'
19+
d3Dispatch.Dispatch.ExtractEventNames<"a b">,
20+
// $ExpectType 'a' | 'b' | 'c'
21+
d3Dispatch.Dispatch.ExtractEventNames<"a b c">,
22+
// $ExpectType 'a' | 'b'
23+
d3Dispatch.Dispatch.ExtractEventNames<"a.1 b">,
24+
// $ExpectType 'a' | 'b'
25+
d3Dispatch.Dispatch.ExtractEventNames<"a.1 b.2">,
26+
// $ExpectType 'a' | 'b'
27+
d3Dispatch.Dispatch.ExtractEventNames<"a b.2">,
28+
// $ExpectType 'a'
29+
d3Dispatch.Dispatch.ExtractEventNames<"a ">,
30+
// $ExpectType 'a'
31+
d3Dispatch.Dispatch.ExtractEventNames<" a">,
32+
// $ExpectType 'a'
33+
d3Dispatch.Dispatch.ExtractEventNames<" a ">,
34+
// $ExpectType 'a' | 'b'
35+
d3Dispatch.Dispatch.ExtractEventNames<" a b ">,
36+
];
37+
1138
// Preparation --------------------------------------------
1239

1340
interface Datum {
@@ -19,24 +46,33 @@ interface ContextObject {
1946
about: string;
2047
}
2148

22-
let dispatch: d3Dispatch.Dispatch<HTMLElement>;
23-
let dispatch2: d3Dispatch.Dispatch<ContextObject>;
2449
let callback: (this: HTMLElement, ...args: any[]) => void;
2550
let callbackOrUndef: ((this: HTMLElement, ...args: any[]) => void) | undefined;
2651
let undef: undefined;
2752

2853
// Signature Tests ----------------------------------------
2954

3055
// create new dispatch object
31-
dispatch = d3Dispatch.dispatch("foo", "bar");
32-
dispatch2 = d3Dispatch.dispatch("start", "end");
56+
const dispatch = d3Dispatch.dispatch<HTMLElement, {
57+
foo: [d?: Datum, i?: number];
58+
bar: [];
59+
}>("foo", "bar");
60+
61+
// $ExpectType Dispatch<HTMLElement, { foo: [d?: Datum | undefined, i?: number | undefined]; bar: [] }>
62+
dispatch;
63+
64+
// in this example, the type-arguments are inferred
65+
const dispatch2 = d3Dispatch.dispatch("start", "end");
66+
67+
// $ExpectType Dispatch<object, Record<"start" | "end", any[]>>
68+
dispatch2;
3369

34-
function cbFn(this: HTMLElement, d: Datum, i: number) {
70+
function cbFn(this: HTMLElement, d?: Datum, i?: number) {
3571
console.log(this.baseURI ? this.baseURI : "nada");
3672
console.log(d ? d.a : "nada");
3773
}
3874

39-
function cbFn2(this: SVGElement, d: Datum, i: number) {
75+
function cbFn2(this: SVGElement, d?: Datum, i?: number) {
4076
console.log(this.baseURI ? this.baseURI : "nada");
4177
console.log(d ? d.a : "nada");
4278
}
@@ -45,6 +81,9 @@ dispatch.on("foo", cbFn);
4581
// @ts-expect-error
4682
dispatch.on("foo", cbFn2); // test fails as 'this' context type is mismatched between dispatch and callback function
4783

84+
// @ts-expect-error -- test fails since there are 3 arguments, but only 2 in the defintion
85+
dispatch.on("foo", (a, b, c) => {});
86+
4887
callback = dispatch.on("bar")!;
4988
callbackOrUndef = dispatch.on("bar");
5089
callbackOrUndef = dispatch.on("unknown");
@@ -60,6 +99,8 @@ dispatch2.call("start", { about: "I am a context object" }, "I am an argument");
6099

61100
dispatch.apply("bar");
62101
dispatch.apply("bar", document.body);
102+
dispatch.apply("bar", document.body, []);
103+
// @ts-expect-error -- `bar` expected 0 arguments
63104
dispatch.apply("bar", document.body, [{ a: 3, b: "test" }, 1]);
64105

65106
dispatch.on("bar", null);
@@ -68,3 +109,166 @@ dispatch.on("bar", null);
68109
const copy: d3Dispatch.Dispatch<HTMLElement> = dispatch.copy();
69110
// @ts-expect-error
70111
const copy2: d3Dispatch.Dispatch<SVGElement> = dispatch.copy(); // test fails type mismatch of underlying event target
112+
113+
const abc = d3Dispatch.dispatch("a", "b", "c");
114+
// valid cases:
115+
abc.on("a", null);
116+
abc.on("b", null);
117+
abc.on("a.1", null);
118+
abc.on("b.b", null);
119+
abc.on("a b", null);
120+
abc.on("a.1 b", null);
121+
abc.on("a.1 b.1", null);
122+
abc.on("a b.2", null);
123+
abc.on(" a", null);
124+
abc.on("a ", null);
125+
abc.call("a");
126+
abc.apply("a");
127+
128+
// invalid, but no error. the arguments are just `never`:
129+
abc.on(" ", (...args) => {
130+
// $ExpectType never
131+
args;
132+
});
133+
134+
// invalid cases:
135+
// @ts-expect-error -- d isn't defined
136+
abc.on("d", null);
137+
// @ts-expect-error -- d isn't defined
138+
abc.on("d.a", null);
139+
// @ts-expect-error -- d isn't defined
140+
abc.on("d e", null);
141+
// @ts-expect-error -- d isn't defined
142+
abc.on("d e.a", null);
143+
// @ts-expect-error -- e isn't defined
144+
abc.on("a e c", null);
145+
// @ts-expect-error -- e isn't defined
146+
abc.on("a e c.1", null);
147+
// @ts-expect-error -- nothing before and after period
148+
abc.on(".", null);
149+
// @ts-expect-error -- d isn't defined
150+
abc.call("d");
151+
// @ts-expect-error -- empty string
152+
abc.call("");
153+
// @ts-expect-error -- d isn't defined
154+
abc.apply("d");
155+
// @ts-expect-error -- empty string before period
156+
abc.on("a.2 b .", null);
157+
// @ts-expect-error -- empty string before period
158+
abc.on("a.2 b .3", null);
159+
// @ts-expect-error -- empty string
160+
abc.apply("");
161+
162+
// this one has no event map, but we still infer the event names
163+
// and validate if an invalid event is passed to .apply() or .call()
164+
const inferred = d3Dispatch.dispatch("a", "b", "c");
165+
166+
inferred.on("a", (...args) => {
167+
// $ExpectType any[]
168+
args;
169+
});
170+
inferred.on("b", (...args) => {
171+
// $ExpectType any[]
172+
args;
173+
});
174+
inferred.on("b.1", (...args) => {
175+
// $ExpectType any[]
176+
args;
177+
});
178+
inferred.on("c", (...args) => {
179+
// $ExpectType any[]
180+
args;
181+
});
182+
// @ts-expect-error -- event name is not defined
183+
inferred.on("invalid", () => {});
184+
inferred.on("a b", (...args) => {
185+
// $ExpectType any[]
186+
args;
187+
});
188+
inferred.on("a.1 b.2", (...args) => {
189+
// $ExpectType any[]
190+
args;
191+
});
192+
// @ts-expect-error -- e is not valid
193+
inferred.on("a.1 b.2 e", (...args) => {});
194+
inferred.call("a");
195+
inferred.call("a", window);
196+
inferred.call("a", window, 1, 2);
197+
inferred.apply("a");
198+
inferred.apply("a", window, []);
199+
inferred.apply("a", window, [1, 2]);
200+
201+
// @ts-expect-error -- event does not exist
202+
inferred.call("d");
203+
// @ts-expect-error -- event does not exist
204+
inferred.call("");
205+
// @ts-expect-error -- event does not exist
206+
inferred.apply("d");
207+
// @ts-expect-error -- event does not exist
208+
inferred.apply("");
209+
210+
interface EventMap {
211+
// eslint-disable-next-line @definitelytyped/no-single-element-tuple-type -- intentional
212+
a: [number];
213+
b: [];
214+
c: [string, boolean];
215+
}
216+
const explicit = d3Dispatch.dispatch<ContextObject, EventMap>("a", "b", "c");
217+
218+
// @ts-expect-error -- type-arguments must match runtime-arguments
219+
d3Dispatch.dispatch<ContextObject, EventMap>("a", "b", "c", "d");
220+
// @ts-expect-error -- type-arguments must match runtime-arguments
221+
d3Dispatch.dispatch<ContextObject, EventMap>("a", "b", "d");
222+
223+
explicit.on("a", function(...args) {
224+
// $ExpectType [number]
225+
args;
226+
// $ExpectType ContextObject
227+
this;
228+
});
229+
explicit.on("b", (...args) => {
230+
// $ExpectType []
231+
args;
232+
});
233+
explicit.on("b.1", (...args) => {
234+
// $ExpectType []
235+
args;
236+
});
237+
explicit.on("c", (...args) => {
238+
// $ExpectType [string, boolean]
239+
args;
240+
});
241+
// @ts-expect-error -- event name is not defined
242+
explicit.on("invalid", () => {});
243+
explicit.on("a b", (...args) => {
244+
// union of `a` and `b`'s types
245+
// $ExpectType [number] | []
246+
args;
247+
});
248+
explicit.on("a.1 b.2", (...args) => {
249+
// union of `a` and `b`'s types
250+
// $ExpectType [number] | []
251+
args;
252+
});
253+
// @ts-expect-error -- e is not valid
254+
explicit.on("a.1 b.2 e", (...args) => {});
255+
256+
explicit.apply("a", this, [123]);
257+
explicit.apply("b", this, []);
258+
explicit.apply("c", this, ["", true]);
259+
// @ts-expect-error -- event does not exist
260+
inferred.apply("d");
261+
// @ts-expect-error -- invalid arguments
262+
explicit.apply("a", this, []);
263+
// @ts-expect-error -- invalid arguments
264+
explicit.apply("b", this, [1]);
265+
266+
explicit.call("a", this, 123);
267+
explicit.call("b", this);
268+
explicit.call("c", this, "", true);
269+
// @ts-expect-error -- event does not exist
270+
inferred.call("d");
271+
// @ts-expect-error -- invalid arguments
272+
explicit.call("a", this);
273+
// @ts-expect-error -- invalid arguments
274+
explicit.call("b", this, 1);

types/d3-dispatch/index.d.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
// Last module patch version validated against: 3.0.1
22

3-
export interface Dispatch<T extends object> {
3+
/** helper function, maps over a union type */
4+
type MapUnion<K extends keyof T, T> = K extends any ? T[K] : never;
5+
6+
export namespace Dispatch {
7+
/** given a string like `a.1 b c.2`, it returns a union like `'a' | 'b' | 'c'` */
8+
type ExtractEventNames<Input extends string> = Input extends "" ? never
9+
: Input extends `${infer A} ${infer B}` // multiple events
10+
? ExtractEventNames<A> | ExtractEventNames<B>
11+
: Input extends `${infer A}.${string}` // single event with a name
12+
? A
13+
: Input; // single event with no name
14+
15+
/**
16+
* defines all the events that can be emitted.
17+
* Each property is the arguments for that event.
18+
*/
19+
interface GenericEventMap {
20+
[eventName: string]: any[];
21+
}
22+
}
23+
24+
export interface Dispatch<This extends object, EventMap extends Dispatch.GenericEventMap = Dispatch.GenericEventMap> {
425
/**
526
* Like `function.apply`, invokes each registered callback for the specified type,
627
* passing the callback the specified arguments, with `that` as the `this` context.
@@ -10,7 +31,7 @@ export interface Dispatch<T extends object> {
1031
* @param args Additional arguments to be passed to the callback.
1132
* @throws "unknown type" on unknown event type.
1233
*/
13-
apply(type: string, that?: T, args?: any[]): void;
34+
apply<U extends keyof EventMap>(type: U, that?: This, args?: EventMap[U]): void;
1435

1536
/**
1637
* Like `function.call`, invokes each registered callback for the specified type,
@@ -22,19 +43,24 @@ export interface Dispatch<T extends object> {
2243
* @param args Additional arguments to be passed to the callback.
2344
* @throws "unknown type" on unknown event type.
2445
*/
25-
call(type: string, that?: T, ...args: any[]): void;
46+
call<U extends keyof EventMap>(type: U, that?: This, ...args: EventMap[U]): void;
2647

2748
/**
2849
* Returns a copy of this dispatch object.
2950
* Changes to this dispatch do not affect the returned copy and vice versa.
3051
*/
31-
copy(): Dispatch<T>;
52+
copy(): Dispatch<This, EventMap>;
3253

3354
/**
3455
* Returns the callback for the specified typenames, if any.
3556
* If multiple typenames are specified, the first matching callback is returned.
3657
*/
37-
on(typenames: string): ((this: T, ...args: any[]) => void) | undefined;
58+
on<Source extends string>(
59+
typenames: Source,
60+
): Dispatch.ExtractEventNames<Source> extends keyof EventMap
61+
? ((this: This, ...args: MapUnion<Dispatch.ExtractEventNames<Source>, EventMap>) => void) | undefined
62+
: never;
63+
3864
/**
3965
* Adds or removes the callback for the specified typenames.
4066
* If a callback function is specified, it is registered for the specified (fully-qualified) typenames.
@@ -44,7 +70,12 @@ export interface Dispatch<T extends object> {
4470
* To specify multiple typenames, separate typenames with spaces, such as start end or start.foo start.bar.
4571
* To remove all callbacks for a given name foo, say dispatch.on(".foo", null).
4672
*/
47-
on(typenames: string, callback: null | ((this: T, ...args: any[]) => void)): this;
73+
on<Source extends string>(
74+
typenames: Source,
75+
callback: Dispatch.ExtractEventNames<Source> extends keyof EventMap
76+
? ((this: This, ...args: MapUnion<Dispatch.ExtractEventNames<Source>, EventMap>) => void) | null
77+
: never,
78+
): this;
4879
}
4980

5081
/**
@@ -53,5 +84,13 @@ export interface Dispatch<T extends object> {
5384
* @param types The event types.
5485
* @throws "illegal type" on empty string or duplicated event types.
5586
*/
56-
// eslint-disable-next-line @definitelytyped/no-unnecessary-generics
57-
export function dispatch<T extends object>(...types: string[]): Dispatch<T>;
87+
/* eslint-disable @definitelytyped/no-unnecessary-generics */
88+
export function dispatch<
89+
This extends object,
90+
EventMap extends Record<EventNames, any[]>,
91+
const EventNames extends keyof any = keyof EventMap,
92+
>(
93+
...types: EventNames[]
94+
): Dispatch<This, EventMap>;
95+
96+
export {};

types/d3-dispatch/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
{
3030
"name": "Nathan Bierema",
3131
"githubUsername": "Methuselah96"
32+
},
33+
{
34+
"name": "Kyle Hensel",
35+
"githubUsername": "k-yle"
3236
}
3337
]
3438
}

0 commit comments

Comments
 (0)