DEV Community

Karol Hinz
Karol Hinz

Posted on

Advanced TypeScript's capabilities in real-life scenario

Brief

In this article I present how I built statically-typed, dynamic TypeScript client for HTTP weather data API. This is a real-life case I encountered during my work.

The article focuses on TypeScript's capabilities of types composing and inferring. I describe how these capabilities can be utilized to define statically-typed, yet dynamically-composed types model for weather data metrics.

Code sample

I've created this StackBlitz with all code mentioned in the article wired up, so you can see how the pattern works and experiment with it by yourself.

Introduction

I'm recently working on remote weather stations system. Each station's task is to constantly collect weather data. Metrics are sent to a server and stored in a database and so on... But that's a whole different story right now.

Let's focus on weather data.

Single weather metric consists of values and is defined as follows:

timestamp: Date;
temperature: number;
pressure: number;
humidity: number;
rain: number;
sun: number;
windDirection: number;
windVelocity: number;
dew: number;

During work I encountered interesting problem. I needed to read metrics with either few or all values selected, preferably retrieved in single database roundtrip. Therefore I've built HTTP API endpoint allowing me to do exactly that. The endpoint gave me great dose of flexibility which I really liked but I was having difficulty in achieving the same amount of neatness in consumer.

Skipping some details, to retrieve station's metrics, server expects as parameter bit flags MetricValue indicating which values consumer wants to receive. Server then returns selected values along with corresponding timestamps packed as JSON array. This approach allows me to reuse the same metrics endpoint without need to implement specific use cases separately.

MetricValue bit flags definition in TypeScript looks like this:

enum MetricValue {
  Temperature = 1 << 0,
  Pressure = 1 << 1,
  Humidity = 1 << 2,
  Rain = 1 << 3,
  Sun = 1 << 4,
  WindDirection = 1 << 5,
  WindVelocity = 1 << 6,
  Dew = 1 << 7
}

Example requests:

> GET metrics/1
// 1 === MetricValue.Temperature
[{timestamp: "00:01", temperature: 20.2}, {timestamp: "00:02", temperature: 20.3}, ...]

> GET metrics/3
// 3 === MetricValue.Temperature | MetricValue.Pressure
[{timestamp: "00:01", temperature: 20.2, pressure: 1013}, {timestamp: "00:02", temperature: 20.3, pressure: 1013.1}, ...]

Cool, isn't it? But how can we type this creature?

Defining API client's requirements

To be fair, my first idea, which would probably Just Work™, was type single MetricDto like follows:

interface MetricDto {
  readonly timestamp: string;
  readonly temperature?: number;
  readonly pressure?: number;
  // other optional values ...
}

Like I said, this would be probably fine but it wasn't completely satisfying due to fact of losing compile-time information of what values I am exactly retrieving. All these nullable values are just awful and can lead to errors or far too many undefined checks.

Oh, and I spent recently too much time reading about fancy TypeScript's type system features to just let it go.

I decided to come up with something more strict and composable and I'll show you everything I discovered.

Breaking contract declaration into components

First of all, I decided to break my MetricDto into separate interfaces. One for each value.

interface TimestampDto {
  readonly timestamp: string;
}

interface TemperatureDto {
  readonly temperature: number;
}

interface PressureDto {
  readonly pressure: number;
}

// ...

Note I declared specific properties as non-optional as opposed to original MetricDto declaration. This allows me to reuse above interfaces in different scenarios without redeclaring API contracts.

With original MetricDto declaration broken down to single-value interfaces, we can type MetricDto as:

type MetricDto =
    TimestampDto
  & Partial<TemperatureDto>
  & Partial<PressureDto>
  & Partial<HumidityDto>
  & Partial<RainDto>
  & Partial<SunDto>
  & Partial<WindDirectionDto>
  & Partial<WindVelocityDto>
  & Partial<DewDto>;

I used here predefined type called Partial<T> which causes all properties of generic argument T to be marked as optional.

By the way, we can write custom mapped types like Partial<T>, but we don't need any in this case.

In result, we get model of MetricDto which is equivalent to initial one. The difference is, the new one is far more composable.

Ok, that's cool, you may say, but it gives us basically nothing new. We rewrote the same code with some fancy stuff, but we didn't get any real benefits, except for some misty promise of composability.

You're right and it's time to push things little bit further.

Mapping MetricValue flag to its corresponding DTO type

While wondering what else can I do, I found out that I can use enum values as types. The condition to do so is to declare only constant enum values, so I had to change MetricValue declaration to contain precalculated flags values.

enum MetricValue {
  Temperature = 1,
  Pressure = 2,
  Humidity = 8,
  Rain = 16,
  Sun = 32,
  WindDirection = 64,
  WindVelocity = 128,
  Dew = 256
}

Maybe it's not the most convenient but it's more than fair trade-off for what we'll achieve soon. As I mentioned, this change lets us to use enum values as types. So, we can do now things like:

type Temperature = MetricValue.Temperature;

I discovered that I can use this feature along with conditional types to map MetricValue with its corresponding *Dto interface.

type Dto<T extends MetricValue> =
    T extends MetricValue.Temperature ? TemperatureDto
  : T extends MetricValue.Pressure ? PressureDto
  : T extends MetricValue.Humidity ? HumidityDto
  : T extends MetricValue.Rain ? RainDto
  : T extends MetricValue.Sun ? SunDto
  : T extends MetricValue.WindDirection ? WindDirectionDto
  : T extends MetricValue.WindVelocity ? WindVelocityDto
  : T extends MetricValue.Dew ? DewDto
  : never;

Example usage:

type Result = Dto<MetricValue.Temperature>;

// Result is TemperatureDto.

Hmm, that's interesting but it's still not too dynamic. What we really want is a way to find out at compile-time which MetricValues consumer is querying and thus what will be exact returned DTO type.

Magic kicks in - inferring DTO type from generic tuple of MetricValue types

After some time of getting familiar and struggling with type inference using infer keyword, I finally came up with the following solution:

type UnionDto<T extends MetricValue[]> = T extends (infer U)[] ? (U extends MetricValue ? Dto<U> : never) : never;

What this mapping does is taking tuple of MetricValue types and then creating from the tuppled types a union of mapped *Dto types. It's best seen on example:

type Result = UnionDto<[MetricValue.Temperature, MetricValue.Pressure]>;

// Result is TemperatureDto | PressureDto.

Now we are getting somewhere. Union is not something that fullfills our requirements, though. Intersection is what we need. Luckily, that's not a problem because we can convert union to intersection in no time.

type UnionToIntersection<T> = (T extends any ? (x: T) => void : never) extends ((x: infer U) => void) ? U : never;

We can now create type properly resolving MetricValues to composed Dto.

type CompositeDto<T extends MetricValue[]> = UnionToIntersection<UnionDto<T>>;

Which gives us ability to do exactly what we wanted - infer resulting Dto from MetricValues passed as arguments.

type Result = CompositeDto<[MetricValue.Temperature, MetricValue.Pressure]>;

// Result is TemperatureDto & PressureDto.

Wiring things up

We finally have everything we need to model type we're after all the time. Let's look at it.

type Metric<T extends MetricValue[]> = TimestampDto & CompositeDto<T>;

Metric<T> is type representing metric fetched from API which consists of mandatory timestamp and intersects additional Dtos representing specific metric's values which are inferred from passed MetricValues.

type Result = Metric<[MetricValue.Temperature, MetricValue.Pressure]>;

// Result is TimestampDto & TemperatureDto & PressureDto.

That's lot of theory but how we can actually use it in practice?

Building API client

Finally, we can assemble the API client. I'll obscure implementation details to keep things simple, but crucial is function resembling this one:

function fetchMetrics<T extends MetricValue[]>(stationId: number, ...metrics: T): Promise<Metric<T>[]> {
  // Details of request...
}
// 'metrics' is array of TimestampDto & TemperatureDto & PressureDto.
fetchMetrics(stationId, MetricValue.Temperature, MetricValue.Pressure).then(metrics =>
  // IDE hints and static typing goodness available without writing any explicit types!
  for (const metric of metrics) {
    console.log(metric.temperature, metric.pressure);
  }
});

Neat!

Our fetchMetrics function is statically-typed and its returned DTO type is dynamically composed, based on what metric values we are requesting.

We built powerful client which we can now reuse in many use cases without needing to handle whole bunch of nullable properties or manually writing narrowed types for specific use case.

If that's not cool then I don't know what is.

Summary

Since the very beginning of my TypeScript adventure, I've heard about its rich typing features. I must admit that until this very moment I wasn't aware the typing features were in fact that rich.

The pattern surely has more applications than just that. Please, share if you discover more clever stuff like this.

Hope you enjoyed my ideas. I'm keen on discussing any matters related to the subject.

Top comments (4)

Collapse
 
tokland profile image
Arnau Sanchez • Edited

Enums are cool, but using string literals makes working with generics much easier:

gist.github.com/tokland/9dcae2ac7a...

I don't think you can write it this way with enums.

Collapse
 
zeno profile image
Zeno

Really good piece.

Don't think it's that overengineering. Everything seems straightforward and simple, yet it's very powerful.

Collapse
 
epolanski profile image
Enrico Polanski • Edited

I really miss the point of all of this type overengineering but learned few things regardless.

Collapse
 
ninjah187 profile image
Karol Hinz • Edited

The main point was learning myself TypeScript's type inferring and conditional types. It's in private project so I do have a comfort of doing so. I do agree it's pretty overengineered and rather not something one would need in a daily production code.

Still, I'm glad I could share my findings and I'm happy you found it useful somehow.