DEV Community

loading...

Thinking in data contracts

TK
Sharing knowledge https://leandrotk.github.io
Originally published at leandrotk.github.io Updated on ・6 min read

This post was first published at TK's blog.

In frontend development, it is common to consume API data and use it to render user interfaces. But sometimes, the API data is not exactly how we want to work with. So we manage to map the API data into the App state.

But it doesn't need to be complex. It can be just a simple mapper function, an API data contract, and the App state contract.

I'll show an example in the context of a Redux app and how we can make the state consistent.

We start with the initial state contract.

type Person = {
  id: number,
  name: string,
  email: string
};
Enter fullscreen mode Exit fullscreen mode

And use the contract type in the initial state definition:

const initialState: Person = {
  id: 0,
  name: 'TK',
  email: 'tk@mail.com'
};
Enter fullscreen mode Exit fullscreen mode

After the app state definition, we can think of the API contract. We can just implement a type PersonAPI with all the types needed for the data.

type PersonAPI = {
  id: number,
  name: string,
  email: string
};
Enter fullscreen mode Exit fullscreen mode

Now that we have our contract defined, we can work with the data mapping. it doesn't need to be a super complex class. It can be a simple pure function, receiving PersonAPI data and transforming it into a Person data.

const fromAPI = (person: PersonAPI): Person => ({
  id: person.id,
  name: person.name,
  email: person.email
});
Enter fullscreen mode Exit fullscreen mode

That's pretty simple! And how do we use it?

const payloadAPI = {
  id: 1,
  name: 'TK',
  email: 'tk@mail.com'
};

const person: Person = fromAPI(payloadAPI); // { id: 1, name: 'TK', email: 'tk@mail.com' }
Enter fullscreen mode Exit fullscreen mode

Data comes in. Data comes out. Everything pure.

Here we have a very simple mapping, no involved logic. But what if the API data we receive has no name, but firstName and lastName? We want to transform the firstName and lastName into a name attribute in the Person contract.

The PersonAPI type:

type PersonAPI = {
  id: number,
  firstName: string,
  lastname: string,
  email: string
};
Enter fullscreen mode Exit fullscreen mode

The Person type:

type Person = {
  id: number,
  name: string,
  email: string
};
Enter fullscreen mode Exit fullscreen mode

In our name, we need to join strings. Basically doing string interpolation:

`${person.firstName} ${person.lastName}`
Enter fullscreen mode Exit fullscreen mode

So our mapping function would be something like:

const fromAPI = (person: PersonAPI): Person => ({
  id: person.id,
  name: `${person.firstName} ${person.lastName}`,
  email: person.email
});
Enter fullscreen mode Exit fullscreen mode

Great! Transforming data for UI rendering.

Next step: imagine our lastName is an optional database column. So the API endpoint can return it... or not!

We can use the Typescript Optional Property. It tells us: "It is an optional property, it has this type, but the data can be here... or not!"

So we use it in our API contract:

type PersonAPI = {
  id: number,
  firstName: string,
  lastName?: string,
  email: string
};
Enter fullscreen mode Exit fullscreen mode

Nice! Now we know that we need to do some kind of logic to build the name attribute.

  • It has the lastName property: concat firstName and lastName
  • It hasn't the lastName: just return the firstName value
const fromAPI = (person: PersonAPI): Person => {
  let name: string;
  if (person.lastName) {
    name = `${person.firstName} ${person.lastName}`
  } else {
    person.firstName
  }
  return {
    id: person.id,
    name,
    email: person.email
  };
};
Enter fullscreen mode Exit fullscreen mode

But we can also transform this let statement into a const by doing a ternary operation:

const fromAPI = (person: PersonAPI): Person => {
  const name: string = person.lastName
    ? `${person.firstName} ${person.lastName}`
    : person.firstName;
  return {
    id: person.id,
    name,
    email: person.email
  };
};
Enter fullscreen mode Exit fullscreen mode

Or better: separate its responsibility into a function that builds the name!

const buildPersonName = (person: PersonAPI): string =>
  person.lastName
    ? `${person.firstName} ${person.lastName}`
    : person.firstName;
const fromAPI = (person: PersonAPI): Person => {
  const name: string = buildPersonName(person);
  return {
    id: person.id,
    name,
    email: person.email
  };
};
Enter fullscreen mode Exit fullscreen mode

We separate the responsibility of each function. Great! It is easier to test our functions now.

Next phase: using the API data to build a new app state. Imagine we want to know if the person is active. The business rule is: the person status should be active and the last visit should be within this week (in the last 7 days).

Our API contract first:

type PersonAPI = {
  id: number,
  firstName: string,
  lastName?: string,
  email: string,
  status: string,
  lastVisit: Date
};
Enter fullscreen mode Exit fullscreen mode

We will use these properties: status and lastVisit.

Our app state contract second:

type Person = {
  id: number,
  name: string,
  email: string,
  active: boolean
};
Enter fullscreen mode Exit fullscreen mode

The business rule now:

  • Person status should be active
person.status === 'active'
Enter fullscreen mode Exit fullscreen mode
  • Person last visit should be in the last 7 days
person.lastVisit >= new Date(Date.now() - 7 * 24 * 3600 * 1000);
Enter fullscreen mode Exit fullscreen mode

Now our mapping function:

const fromAPI = (person: PersonAPI): Person => {
  const name: string = buildPersonName(person);
  const active: boolean = person.status === 'active' && person.lastVisit >= new Date(Date.now() - 7 * 24 * 3600 * 1000);
  return {
    id: person.id,
    name,
    email: person.email,
    active
  };
};
Enter fullscreen mode Exit fullscreen mode

Let's refactor it! We will start with the status thing. 'active' is a string. To define it in a data structure and enable reusability, we can use Typescript's Enum.

enum PersonStatus {
  Active = 'active',
  Inactive = 'inactive'
};
Enter fullscreen mode Exit fullscreen mode

We use it like:

PersonStatus.Active // 'active'
PersonStatus.Inactive // 'inactive'
Enter fullscreen mode Exit fullscreen mode

The person status logic comes easy with this feature:

person.status === PersonStatus.Active;
Enter fullscreen mode Exit fullscreen mode

Now the last visit thing. Instead of random numbers, what about making it a little bit more descriptive? This is 1 day in milliseconds:

const oneDayInMilliseconds: number = 24 * 3600 * 1000;
Enter fullscreen mode Exit fullscreen mode

This is 7 days in milliseconds:

const sevenDaysInMilliseconds: number = oneDayInMilliseconds * 7;
Enter fullscreen mode Exit fullscreen mode

And this is a week ago:

const weekAgo: Date = new Date(Date.now() - sevenDaysInMilliseconds);
Enter fullscreen mode Exit fullscreen mode

Now our logic comes easy:

person.lastVisit >= weekAgo;
Enter fullscreen mode Exit fullscreen mode

We can now join all together in a function called isActive that returns a boolean?

const isActive = (person: PersonAPI): boolean => {
  const oneDayInMilliseconds: number = 24 * 3600 * 1000;
  const sevenDaysInMilliseconds: number = oneDayInMilliseconds * 7;
  const weekAgo: Date = new Date(Date.now() - sevenDaysInMilliseconds);
  return person.status === PersonStatus.Active &&
    person.lastVisit >= weekAgo;
};
Enter fullscreen mode Exit fullscreen mode

I really want to separate the weekAgo "logic" into a new function. And I also want to name the statements.

const getWeekAgo = (): Date => {
  const oneDayInMilliseconds: number = 24 * 3600 * 1000;
  const sevenDaysInMilliseconds: number = oneDayInMilliseconds * 7;
  return new Date(Date.now() - sevenDaysInMilliseconds);
};
const weekAgo: Date = getWeekAgo();
Enter fullscreen mode Exit fullscreen mode

Naming our statements, it looks like:

const hasActiveStatus: boolean = person.status === PersonStatus.Active;
const lastVisitInSevenDays: boolean = person.lastVisit >= weekAgo;
Enter fullscreen mode Exit fullscreen mode

So our final isActive function looks beautiful:

const isActive = (person: PersonAPI): boolean => {
  const weekAgo: Date = getWeekAgo();
  const hasActiveStatus: boolean = person.status === PersonStatus.Active;
  const lastVisitInSevenDays: boolean = person.lastVisit >= weekAgo;
  return hasActiveStatus && lastVisitInSevenDays;
};
Enter fullscreen mode Exit fullscreen mode

And our mapping function keeps simple:

const fromAPI = (person: PersonAPI): Person => {
  const name: string = buildPersonName(person);
  const active: boolean = isActive(person);
  return {
    id: person.id,
    name,
    email: person.email,
    active
  };
};
Enter fullscreen mode Exit fullscreen mode

Just a few tweaks: Object Property Value Shorthand for id and email.

const fromAPI = (person: PersonAPI): Person => {
  const { id, email }: PersonAPI = person;
  const name: string = buildPersonName(person);
  const active: boolean = isActive(person);
  return {
    id,
    name,
    email,
    active
  };
};
Enter fullscreen mode Exit fullscreen mode

Learnings

So what have we learned here?

  • Data contracts help us better define our data structures, the state we want in our frontend to render UI properly.
  • It also serves as good documentation: a better understanding of our API response and the app state we need to deal with.
  • Another cool benefit is when we define the data types and use it in the initial state. We make our system really consistent if we preserve the state contract across the application.
  • It doesn't need to be complex. Simple & pure functions only. Separate the responsibility of each function and we are good to go. It also helps us when testing.

I hope I could show a good overview of the data contracts, simples functions, and the single responsibility principle. In software engineering, it is really easy to make everything complex and mess it up. But if we think carefully about our data, the data structures we are using, and how we manage complexity and logic, I think we have a good chance of building good software.

Resources

Discussion (1)

Collapse
itsjzt profile image
Saurabh Sharma

Only benefit of static types are tighter data contracts