Translating API responses into type-safe interfaces with TypeScript
HTTP APIs using JSON as a data exchange format are characterized by a fundamental flaw: information about types is discarded the moment the data is serialized from the web service’s programming language of choice into its JSON representation. Type safety is literally gone at this step, but does it mean it can’t be enforced at the consumer’s end?
Mapping response data to domain types
Let's consider a data transfer object type of the following shape:
interface ElementDto {
opacity: number;
color: string;
label: string | null;
}
It’s a simple interface (product type) describing some abstract element properties. Notice how at least one property was explicitly declared as optional — the label
field might contain a string, as well as it can have no meaningful value at all.
Data transfer objects (DTOs) represent the domain objects in their serialized data exchange form. In the case of most traditional web applications, that means JSON used to communicate with some web service’s API. When reading data from a remote server, as a next step, we want to operate on a more robust definition of the domain type:
import { Maybe } from "true-myth";
type Color = "lightcyan" | "darkorchid";
interface Element {
opacity: number;
color: Color;
label: Maybe<string>;
}
Partner with Typesctript, React and Next.js experts to make an impact with beautiful and functional products built with rock-solid development practices. Explore the offer >>
Notice how that type’s definition is richer than its DTO counterpart. While opacity
stayed the same (a plain number
), the color
is assumed to not be any arbitrary string, but rather on of the Color
closed set’s values. An interesting thing happened to the label
— because of its nullability, it’s wrapped here into a Maybe<T>
type — a safe wrapper for possibly missing values (similar to Java’s Optional
, Scala’s Option
or Haskell’s Maybe
). I wrote about it in my article — The higher order of types.
While the latter seems to be more precise about possible valid values (eg. the color
field), as well as safer to use (functional API of the Maybe
type on label
), we need to have a way of mapping the DTO form into the domain type. For that, it makes sense to write a few functions. Let’s look at an example of mapping an arbitrary string into a valid member of the Color
type union (sum type):
const dtoToDomain: Record<string, Color | undefined> = {
clr_light: "lightcyan",
clr_dark: "darkorchid",
};
const mapDtoToValues = (dtoColor: string): Color | never => {
const domainColor = dtoToDomain[dtoColor];
if (typeof domainColor !== "undefined") {
return domainColor!;
}
throw new Error(`Could not map the value "${dtoColor}" to a domain value of type Color`);
}
The logic of decoding the value of the color
field (plain string) assumes several expected “input” values that can be mapped into domain type’s values. In case of an unrecognized value, for which no domain counterpart exists, an exception is raised (remember to catch and handle it somewhere down the call stack!). Depending on a use case, instead of raising an exception, either some default (fallback) value, or the one wrapped in true-myth’s Result type could be returned (for functional programming folks, think Either).
Having that, let’s see how we can put together a complete domain object from the DTO values:
import { Maybe } from "true-myth";
const mapElementDtoToElement = ({ opacity, color, label }: ElementDto): Element => ({
opacity,
color: mapDtoToValues(color),
label: Maybe.of(label),
});
As it was pointed out before, the opacity
field remains a plain number
in both types, hence no need for any mapping logic here. The color
is mapped via the mapDtoToValues
function, and the label
is wrapped into a type-safe container via a call to static Maybe.of(..)
. All composed together result in a complete mapping from ElementDto
to Element
type.
Why all the hassle?
Based on my experience of following this pattern in a handful of commercial, production-running projects, there are at least a few reasons to keep the robust definition of domain model and data transfer object counterpart, along with the mapping functions.
First of all, APIs can’t always be trusted. If there’s even a slight chance that a field might be null or not defined at all, it is always a good idea to be explicit about it and express that fact at the type level. TypeScript is very helpful in that regard, as it allows type unions like string | null
, as well as it supports generic types, enabling higher-order wrappers like Maybe<T>
.
Second, if the field is expected to contain one of the enumerable values (sum type), it is still safer to not trust the API completely. Version changes could introduce new type union members or deprecate existing ones. That “narrowing” step translates the known input into one of the domain type members and rejects any unknown values (eg. by falling back to some default value, or raising an exception). For fixed, enumerable values, always prefer type union (or an enum) instead of eg. plain string.
Third, dealing with nullable values wrapped in a Maybe
container is, in my experience, the most convenient approach of all available in the language so far. Yes, there’s optional chaining, which combined with the nullish coalescing operator gives a very nice syntax for such situations, but compared to the rich API of true-myth’s Maybe
, the latter enables much more flexibility (mainly via methods like flatMap
, all
, ap
, and conversions from/to Result
).
Careful mapping of data transferred from some API to domain types lets us avoid defensive programming style “later” in the code operating on those objects. Invalid values can be filtered out early and absent values are no longer a problem, as the TypeScript compiler assists us, making sure that illegal states of the application are unlikely to happen.
Summary
Using statically typed languages on the backend, we always care for the type safety and soundness. There’s no reason to not be strict about it while using TypeScript, too!