Classes for structure, plain objects for data
I write a lot of Typescript at the moment, both professionally and in hobby projects. One idiom – picked up from Clojure – that I lean on quite a lot now is to use classes for structural portions of the codebase, and plain old JavaScript objects for the data that travels through it. It turns out TypeScript is a surprisingly good match for this approach.
The structural parts are often a pretty close match with the stateful parts, but are also sometimes just components of the program that I want to be able to swap out implementations of (loggers, data respositories, web servers, etc). I use classes as it’s the easiest way to program to an interface with some encapsulation. I also find the priviledged position of the constructor useful: TypeScript’s readonly field support is quite good. Nearly every component of this is going to be exposed via a very simple dependancy injection library I wrote. I’ll cover that in another post later.
The real meat of the program – working with data that flows through the system – I leave as simple as possible. TypeScript supports this with a combination of its structural typing and comprehensive support for defining types in terms of other types. At the simplest level this is just unions and intersections, but with mapped types and now the new conditional types, quite sophisticated compositions and transformations can be described.
For example, I often use an interface to describe a record from a database table. It’s rare that I use that record as is. Instead individual queries will then use a type defined as the Pick
mapped type to select a subset of the fields, and then intersect that with another Pick
type to represent the join and projection.
This model of using plain data can simplify working with applications that need to frequently ship data between servers, the client, or web workers. There’s no need to marshall and unmarshall data at every boundary – a real win if you are trying to leverage web workers for performance in a client. For services that act as interstitial processors, they can consume wider records than they are aware of, but still deal with what they care about in a typesafe way1.
- Caveat here that you have to validate the data conforms to your types in some way yourself. User defined type guards are useful here.