Typing "data"
How to get better types for "data"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
},
});Leveraging helper functions
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);
},
});Leveraging zod
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);
},
});Why we don't leverage generics
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
draggablesand 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,
onDropwould 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)
canDropchecks 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`.
},
});