Photo by Mohammad Rahmani on Unsplash
Generics & Type Constraints in TypeScript, Go and C#: A Comprehensive Exploration
Introduction
When I started using Typescript a few years back, Generics in Typescript fascinated me greatly. Coming from a dynamically typed language like Python, I noticed that the lack of explicit generic types often led to implicit type assumptions and complicated type checking in my library code. This article is a personal note about my fascination with generics and type constraints. It's also an opportunity to share my passion for the subject.
This exploration will delve into type constraints on generics in three of my favorite statically typed languages: TypeScript, Go, and C#. We'll examine how each language handles generic types, the constraints they offer, and how these constraints contribute to writing robust code.
The theory of Generics in type systems
Various programming languages use generic types with type parameters, often represented as "T
", to act as placeholders for general types. By using these parameters, we can create data structures and algorithms that adapt to different types while maintaining type safety. This approach minimizes code duplication and enhances the expressiveness of the type system, leading to more elegant and efficient software design.
Following Stepanov, we can define generic programming without mentioning language features: Lift algorithms and data structures from concrete examples to their most general and abstract form.
— Bjarne Stroustrup
Generic constraints play a crucial role by limiting the types that can be used, ensuring they meet specific requirements or implement certain interfaces. These constraints give developers control over the types that can fill the placeholder, offering compile-time safety and enabling flexible yet controlled polymorphic behavior. Constraints strike a balance between flexibility and control, guiding the behavior of an abstract type.
The term "constraints" might seem counterintuitive to some, as it implies adding capabilities to a generic type. However, as you increase the required capabilities, you reduce the number of types that can satisfy the generic definition, which is why the term "constraint" is fitting. By defining capabilities, you effectively narrow down the types that can comply with the generic type.
Case study
So for this article, I’ll use an event-tracking system as a case study for implementing generics and type constraints in Typescript, Go, and C#. The system has been dumbed down a lot to ensure some level of simplicity while having the capabilities to demonstrate some of the features of Generics these programming languages provide.
We’ll assume the role of a product analytics SaaS platform like PostHog or Twilio’s Segment, which provides event dispatching SDKs that enable developers to track and send user interaction events across different programming environments in multiple languages: TypeScript (for web and Node.js applications), Go (for backend and systems programming), and C# (for .NET and enterprise environments).
The code defines an EventsDispatcher
that registers event handlers based on specific event types. When the client code triggers an event with a given key, the system looks up the registry for that key and triggers the handler for that event type. This allows the dispatcher to have an abstract representation of the types of events being triggered and how they are handled.
TypeScript: Generics and Duck Typing
In TypeScript, generics enable the creation of reusable and flexible components by allowing type parameters in functions, classes, and interfaces. This is enhanced by duck typing, which uses an object's shape rather than its explicit type. Typescript implements Generics by allowing Type parameters that can be defined alongside the definition be it an interface, type, class, or function. Since the type may not be known at the function definition, the definition can accommodate a variety of types. The general convention for defining these type parameters is by denoting them with capital letters starting from T enclosed in angle brackets: <T>
. Type parameters can take any name, but it’s general convention to use T
& U
, which is especially important for collaboration. A typical declaration for generic types in typescript looks like this
Generic constraints can be used to exert some level of control over the behavior and properties of generic types using the extends
keyword. extends
ensures that the defined generic type conforms to a certain interface or type; explicit or inferred (in the case of duck typing) so for example. The following example demonstrates how to use it. Based on our example earlier we could improve the code by adding some constraints to the types defined earlier.
class EventDispatcher<T> {
private handlers: { [K in keyof T]?: (event: T[K]) => void } = {};
addHandler<K extends keyof T>(key: K, handler: (event: T[K]) => void) {
this.handlers[key] = handler;
}
dispatch<K extends keyof T>(key: K, event: T[K]) {
if (this.handlers[key]) {
const eventHandler = this.handlers[key];
eventHandler(event);
}
}
}
enum EventTypes {
LINK_CLICK = 'LINK_CLICK',
PAGE_VISIT = 'PAGE_VISIT',
}
type Events = {
[EventTypes.LINK_CLICK]: { x: number; y: number; href: string };
[EventTypes.PAGE_VISIT ]: { url: string };
}
const dispatcher = new EventDispatcher<Events>();
dispatcher.addHandler(EventTypes.LINK_CLICK, (event) => {
// Do something cool..
});
dispatcher.addHandler(EventTypes.PAGE_VISIT, (event) => {
// Do something cool...
});
dispatcher.dispatch(EventTypes.LINK_CLICK, { x: 10, y: 20, href: "https://hashnode.com" });
dispatcher.dispatch(EventTypes.PAGE_VISIT, { url: "https://linkedin.com/in/daniel-c-olah" });
The class EventDispatcher is generic over type T
, which represents a map of events. This allows the dispatcher to work with any event configuration that matches the structure defined in T.
The handlers
object stores handlers for each event type. The key is a string literal from the event types (like LINK_CLICK
, PAGE_VISIT
), and the value is a function that takes an event object of the corresponding type. The type
{ [K in keyof T]?: (event: T[K]) => void }
ensures that each key K
is one of the event types in T
(i.e., keyof T
which includes LINK_CLICK
, PAGE_VISIT
) and the handler function takes an event of the corresponding type T[K]
(e.g., { x: number; y: number }
for LINK_CLICK
).
The addHandler
method registers an event handler for a specific event type. It uses the type parameter K
with the constraint that K
must be a union of the keys in object T
, which ensures that the key passed is a valid event type in T
. The handler is typed to accept the correct event data type (T[K]
), so each handler is guaranteed to receive the correct structure.
The dispatch
method triggers the event handler for a given event type and passes the corresponding event data to it. It also uses K
in K extends keyof T
to ensure that the key passed to dispatch is a valid event type and passes the correct event object (T[K]
) to the handler.
The rest of the code should be familiar to TypeScript developers. This example only scratches the surface of what generics can do in TypeScript. I encourage you to learn more from the docs for a complete guide.
Go: Simplicity and Pragmatism
Go places an emphasis on simplicity. In other words, the Go team intentionally left out many features to highlight its best quality: being simple and easy to use. However, the absence of generics in Go has often been cited as one of the top three major issues with the language. Ironically, in many cases, this lack of generics has added complexity to the language. Finally, it came with version 1.18. Generics in Go comes with a similar definition to other languages with a few nuances. The naming convention stays the same, only that the type parameter is enclosed in square brackets [T]
this is referred to as a type parameter list since it can take various generic type arguments. For example, a typical function declaration with a generic type looks like this.
func GMin[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
The GMin
function takes in a T
type parameter that could be any type that satisfies the Orderd constraint that follows it.
Structs and interfaces can also take type parameters. Each type can have a type constraint or meta-type that defines a set of capabilities that the generic type must conform to. From our example earlier, under the hood constraints.Ordered
is a type set that combines a set of types that can be ordered; ideally that would be ints, floats, and strings. Generally, constraints are defined as interfaces, and methods can be declared so that any generic type constrained by that interface must implement it. Type sets are a little more nuanced; even though they are declared in interfaces they are a union of types that implicitly declares that whatever type implements this interface must have the same behavior and capabilities as the types declared in the type set. Of course, this definition is backward compatible with declaring methods in interfaces.
type Ordered interface {
Integer|Float|~string
}
package main
import "fmt"
type LinkClickEvent struct {
X, Y int
href string
}
type Hashable interface {
~int | ~int32 | ~int64 | ~string
}
type EventDispatcher[T, V Hashable] struct {
handlers map[V]func(T)
}
func (ed *EventDispatcher[T, V]) AddHandler(eventType V, handler func(T)) {
if ed.handlers == nil {
ed.handlers = make(map[V]func(T))
}
ed.handlers[eventType] = handler
}
func (ed *EventDispatcher[T, V]) Dispatch(eventType V, event T) {
if handler, ok := ed.handlers[eventType]; ok {
handler(event)
}
}
func main() {
dispatcher := &EventDispatcher[LinkClickEvent]{}
dispatcher.AddHandler("LinkClick", func(event LinkClickEvent) {
// Do something cool...
})
dispatcher.Dispatch("LinkClick", LinkClickEvent{X: 10, Y: 20, href: "https://linkedin.com/in/daniel-c-olah"})
}
At the heart of this example is the Hashable
interface, which uses a type constraint to allow specific numeric types and strings as lookups for the event map.
The true strength of this code lies in the EventDispatcher
type, which leverages two type parameters: T
and V
. The V
parameter is constrained to Hashable
types. By using the ~
symbol, it permits not just the exact types int
, int32
, int64
, and string
, but also any user-defined types based on these underlying types.
The AddHandler
method dynamically creates a map of handlers if it doesn't exist, allowing different event types to be registered seamlessly. it takes in an event type which is defined with the generic type V
. and a handler function that takes in an event of the generic type T
which could be a click or scroll event.
The client code exemplifies how these generic constructs can be used concretely. In the main()
function, an EventDispatcher
is instantiated specifically for LinkClickEvent
, and a string is inferred as the type for V
.
To learn more about Generics in Go, this article provides a comprehensive overview.
C#: Generics and Type Safety
C# offers a robust implementation of generics, providing developers with the ability to create type-safe and reusable code components. Generics in C# are used in classes, interfaces, methods, and delegates, allowing for the definition of algorithms and data structures that work with any data type while maintaining type safety.
In C#, generics are defined using angle brackets, similar to TypeScript, with type parameters typically named T
, U
, etc. For example, a generic list in C# can be defined as List<T>
, where T
is a placeholder for any data type.
To enforce constraints on generic types, C# uses the where
keyword. This allows developers to specify that a type parameter must implement a particular interface, inherit from a specific class, or have a parameterless constructor. This ensures that the generic type has the necessary capabilities, enhancing type safety and reducing runtime errors.
Here's an example of a generic class in C# with constraints:
public interface IEvent
{
string EventType { get; }
}
public class EventData : IEvent
{
public string EventType { get; set; }
}
public class LinkClickEvent : EventData
{
public int X { get; set; }
public int Y { get; set; }
public string href {get; set; }
}
public class EventDispatcher<T> where T : IEvent
{
private readonly Dictionary<string, Action<T>> handlers = new Dictionary<string, Action<T>>();
public void AddHandler(string eventType, Action<T> handler)
{
if (!handlers.ContainsKey(eventType))
{
handlers[eventType] = handler;
}
}
public void Dispatch(string eventType, T eventData)
{
if (handlers.TryGetValue(eventType, out var handler))
{
handler(eventData);
}
}
}
class Program
{
static void Main()
{
var dispatcher = new EventDispatcher<LinkClickEvent>();
dispatcher.AddHandler("LinkClick", e => {
// Do something cool...
});
dispatcher.Dispatch("LinkClick", new LinkClickEvent { X = 10, Y = 20, href = "https://linkedin.com/in/daniel-c-olah" });
}
}
The IEvent
interface defines a contract for event types, requiring an EventType
property. This interface is implemented by EventData
, which serves as a base class for specific event types like LinkClickEvent
. The LinkClickEvent
class extends EventData
and adds properties specific to click events.
The EventDispatcher<T>
class is a generic class constrained by the IEvent
interface. This constraint ensures that only types implementing IEvent
can be used with EventDispatcher
, providing compile-time type safety. The dispatcher maintains a dictionary of event handlers, where each handler is associated with a specific event type.
The addHandler
method registers an event handler for a given event type. It checks if a handler for the specified event type already exists in the dictionary and adds it if not. The dis[ method triggers the appropriate event handler based on the event type and passes the event data to it. It retrieves the handler from the dictionary and invokes it with the provided event data.
In the Main
method, an EventDispatcher
is instantiated for LinkClickEvent
. A handler is added for the "LinkClick" event type. The Dispatch
method is then called to simulate a click event. For more learning on Generics in C# read more here.
Final thoughts
In TypeScript, Go and C#, type constraints on generics are approached with distinct philosophies. C# offers a robust implementation of generics, Go opts for simplicity, using interfaces to define constraints, and relying on type inferences for flexibility in some cases. TypeScript, inheriting its approach from JavaScript, combines explicit interfaces with duck typing, allowing developers to express constraints while accommodating dynamic typing.
Defining constraints in C# is similar to Rust, as both use the where
statement to define constraints. I initially thought about including a Rust example, but due to Rust's explicit programming style, the article would have become too lengthy. Maybe next time.
Recently I have written a lot of Vue2 code and have found generics to be especially helpful when writing mixins that are used across several components, my core philosophy is to maintain abstraction without unnecessary complexity, allowing mixins to remain adaptable across different contexts. In light of this, I deliberately avoid adding type constraints prematurely and advise against it. The guiding principle is flexibility: mixins should be as adaptable as possible, with type constraints introduced only when they become critically necessary.