Type-safe bindings in GoJS
-
Type-safe bindings
Problem
GoJS bindings are not typed as good as they could be, which can lead to issues when working with complex templates and many bindings. Both target and source properties can be any string, which isn't the biggest issue, but the real problem arises when managing numerous templates and bindings. Since these bindings rely on our node or link data, we must ensure that when a property name changes, all related bindings across the application are updated.
Another limitation is that the converter method's first argument can be of any type, meaning we need to manually type it each time we initialize a binding. This can result in incorrect types. Worse, when we change the type of our node data, we may not realize that the bindings are incorrect until we encounter issues during testing. It would be ideal to catch these problems during development rather than at runtime.
Solution
In one of my projects, I implemented a small utility function that helps with typing, manages proper types, and alerts me when bindings need to be updated after modifying the types of node data. It also ensures that the converter logic is correct when the type of data changes (e.g., from an object to a string).
First, we need to create a factory function to generate separate functions, each pre-typed based on our data. This is useful since we can have different types for node data, link data, or model data.
export const createBindingBase = <N extends go.ObjectData>() => <T extends keyof N>( target: string, source: T, converter: (arg: N[T], targetObj: go.GraphObject) => void ) => new go.Binding(target, String(source), converter);
In the code above, we created the
createBindingBase
method, which accepts a generic type representing our data. It returns the originalgo.Binding
instance but ensures that the key of the data object is correct. Additionally, we no longer need to type the value in the converter method because it is inferred from our data object.Next, we can create a method for ourselves without needing to pass the generic type each time:
type NodeData = { a: string; b: boolean; }; export const createDataBinding = createBindingBase<NodeData>();
Now, we can use this binding method easily:
createDataBinding('fill', 'a', (a) => a); // Here typescript will know 'a' is a string createDataBinding('fill', 'b', (b) => b); // Here typescript will know 'b' is a boolean createDataBinding('fill', 'c', (c) => c); // Here typescript will throw an error
This approach handles data bindings, but it would be useful to have similar behavior for model data. To achieve this, we need to call
ofModel()
on our binding. Here’s how we can extend the functionality:type CreateBindingParams<N extends go.ObjectData> = Parameters< ReturnType<typeof createBindingBase<N>> >; export const createModelBinding = (...args: CreateBindingParams<ModelData>) => createBindingBase()(...args).ofModel();
Now, we can use the
createModelBinding
method in the same way as createDataBinding, but it will bind to model data properties and infer types from them.Conclusion
The utility functions for type-safe bindings are highly useful and can significantly speed up development by catching errors early, before we encounter them during runtime. It’s also convenient because we don't need to jump between type definitions and bindings to ensure everything is typed correctly.
However, there are some limitations. For example, this solution doesn't handle all scenarios, such as two-way bindings. But it provides a solid starting point and can be extended to cover more use cases.