I’ve been writing production TypeScript for a little over year and as a hobby for a couple of years longer than that. If you’ve never used TypeScript before, the quick way to describe it is that it’s ES2015+ and types thrown together. Ie. modern JavaScript with real typing.

TypeScript is awesome and I love writing it and over time, I’ve noticed my own style and my own patterns emerge one of which I’d like to share and, hopefully, justify why I stick to those patterns.

Local Interfaces > Global Interfaces

Interfaces in TypeScript are essentially object definitions that describe what an object should minimally look like. For example, if I had a DatabaseConfig interface, it might look something like this:

interface DatabaseConfig {
  host: string,
  port: number,
  password: string
}

function connectToDb(dbConfig: DatabaseConfig) {
  // database connection config
}

What that basically means is that whenever you call the function connectToDb, you need to pass in an object that looks like the DatabaseConfig interface (along with the appropriate typings for its properties).

A pattern I picked up from a Golang styleguide article (I can’t remember which) was the idea of “local interfaces”, interfaces that describe exactly what I need from an object within that single file.

This DatabaseConfig interface, if shared, will grow exponentially to encompass the needs of every function that might touch this object. A createDatabasePool function might additionally look for a poolSize property on that config which will now be required by every function that references this interface, whether they use it or not. Imagine that we also had a function that would return a driver for that particular database so we might need a type property which no function cares about except the driver one.

Basically, sharing interfaces (or using what I call global interfaces) causes interfaces to bloat and to impose artificial requirements on properties that might not even be used by the function/code block/whatever that references the interface. It creates a strange “coupling” between possibly unrelated pieces of code.

Instead, what I suggest is writing interfaces local to a file which describe only the necessary properties required to be in the object by the code in that file. Eg. if you have a createPool function, you might write something like this:

interface PoolConfig {
  poolSize: number
}

export function createPool(config: PoolConfig, driver) {
  // uses config.poolSize somewhere in the code
}

This way, we’re telling the developer working in that file that all we really need is poolSize and we don’t use anything else from that config object.

I’ve found this to be super useful in keeping with the idea that types are really just documentation that the computer can also view and utilize.

Exceptions

There are a couple of exceptions to this rule.

Those exceptions are that if you’re using object Models for your data, you might want to have those models available as interfaces as well to communicate to the developer (and the compiler) that you’re really requiring this model.

You might not care about the exact keys, you might care more about getting the actual Model (or something with the exact same shape).

The other exception to the rule is if you have complex objects that require keeping up with its complex shape. Imagine that you have an object that nests 5 levels deep. It’s more prudent to have a single interface that you import which describes this rather than writing out, quite uselessly, complex nested interfaces.