dropTargetForElements
data (getData()
) and draggable
data (getInitialData()
) are typed as
Record<string | symbol, unknown>
. A loose Record
type is intentionally used as
dropTargetForElements
and draggable
entities are spread out throughout an interface, and there
are no guarentees that particular pieces are present, and what their data
shape will look like
(this is a similiar problem to typing form and field data).
dropTargetForElements({
element: myElement,
onDrop({ source }) {
// `cardId` is typed as as `unknown`
const cardId = source.data.cardId;
// you need to check it's value before you can use it
if (typeof cardId !== 'string') {
return;
}
// handle drop
},
});
A fantastic pattern that we recommend for safe data
types, is to leverage small helper
functions.
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import invariant from 'tiny-invariant';
// We are using a `Symbol` to guarentee the whole object is a particular shape
const privateKey = Symbol('Card');
type Card = {
[privateKey]: true;
cardId: string;
};
function getCard(data: Omit<Card, typeof privateKey>) {
return {
[privateKey]: true,
...data,
};
}
export function isCard(data: Record<string | symbol, unknown>): data is Card {
return Boolean(data[privateKey]);
}
const myDraggable = document.querySelector('#my-draggable');
invariant(myDraggable instanceof HTMLElement);
draggable({
element: myDraggable,
getInitialData: () =>
getCard({
cardId: '1',
}),
});
dropTargetForElements({
element: myDraggable,
// only allow dropping if dragging a card
canDrop({ source }) {
return isCard(source.data);
},
onDrop({ source }) {
const data = source.data;
if (!isCard(data)) {
return;
}
// data is now correctly typed to `Card`
console.log(data);
},
});
You can also leverage runtime type checking libraries like zod to type your
data
.
import { z } from 'zod';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import invariant from 'tiny-invariant';
const CardSchema = z.object({
cardId: z.string(),
});
type Card = z.infer<typeof CardSchema>;
const myDraggable = document.querySelector('#my-draggable');
invariant(myDraggable instanceof HTMLElement);
draggable({
element: myDraggable,
getInitialData: (): Card => ({
cardId: '1',
}),
});
dropTargetForElements({
element: myDraggable,
// only allow dropping if dragging a card
canDrop({ source }) {
return CardSchema.safeParse(source.data).success;
},
onDrop({ source }) {
const result = CardSchema.safeParse(source.data);
if (!result.success) {
return;
}
// result.data is now correctly typed to `Card`
console.log(result.data);
},
});
A common approach for solving similiar problems is to enable the ability to provide generics to
pieces to force it's data
type.
// Note: this is not real API
dropTargetForElements<{ cardId: string }>({
element: myElement,
onDrop({ source }) {
// cardId would be typed as `string` by the Generic
const cardId = source.data.cardId;
},
});
This approach has some drawbacks for our use case though:
- Because entities (eg
draggables
and drop targets) can be in disconnected source files and or in disconnected pieces of the interface, there are no guarentees that particular pieces will exist in an interface, or that those pieces will provide the data shapes expected. - Some pieces in your system might not use Generics, or might use the wrong Generics, and so you could get runtime errors.
- Things get complicated if you want a single event handler to handle the dropping of many different
types of
data
. - To use the example above,
onDrop
would be called with all drop events, so the generic would not always be accurate.
Exploring a built in guard (eg acceptData())
The intention of this section is to show that we have thought about adding a built in guard function, but that doing so doesn't work out that well.
Conceptually we could introduce an acceptData()
guard.
type Card = { cardId: string; instanceId: symbol };
dropTargetForElements<Card>({
element: myElement,
// Note: this is not real API.
// Validate that `data` is the right type
acceptData({ data }): data is Card {
// We need to assert that `data` is a `Card`
return isCard(data);
},
canDrop({ data }) {
// let's assume that this is called after `acceptData` and
// `data` is now typed. Now we can do our additional checks.
return data.instanceId === ourInstanceId;
},
onDrop({ source }) {
// cardId could be typed as `string`
const cardId = source.data.cardId;
},
});
- We still need to do run time checking (it's now just in a seperate place)
canDrop
checks are split up into different functions
It seems to be cleaner to let consumers do their own runtime checking and not introduce an
additional acceptData()
guard.
// Real API
dropTargetForElements({
element: myElement,
canDrop({ source }) {
return isCard(source.data) && source.data.instanceId === ourInstanceId;
},
onDrop({ source }) {
if (!isCard(source.data)) {
return;
}
// source.data is now typed as `Card`.
},
});