Types are playing an essential part in modern React applications. They’re the foundation for typing component props, shaping unstructured data like JSON, and defining request bodies. And that’s only the tip of the iceberg of use cases you might run into.
We, as developers, often focus so much on business logic, performance, security, and all the other critical aspects of our apps that we just forget about the type system. As the project evolves, types get messy - names are inconsistent, and there’s no clear place to store them. That hurts the developer experience. Teams start using any
more commonly, and it leads to inconsistency between the backend and the frontend. As a result, bugs emerge.
Today we’re talking about the model layer, which is a part of the five-layer MVVM architecture
Its goal is to tackle all of the hurdles listed above, which means fewer bugs, zero any
usage (unless it’s absolutely required), and easier refactoring.
TIP: If you haven’t read my MVVM Introduction yet, I highly recommend doing that first. It’ll help you understand where the model layer fits in the bigger picture.
In this article, you’ll learn:
- How to add types to the unstructured data you receive and send to the external sources.
- How to structure the data so it can be conveniently displayed on the UI.
- How to organize the prop types in the MVVM style.
Before we begin, I just want to remind, that in this MVVM series, we’re building a country quiz app. The app prompts the user with the flag, and the user has to guess the name of the country. Once they entered and sent the answer, we save it in the localStorage. Then we display the answer history in the left sidebar. You can find the source code on GitHub and the Demo on CodeSandbox.
Payload: Typing the API Response
Payloads define a structure for the data your app receives from the external data source. For example, it can be a type of GET
query or WebSocket channel response.
In the article explaining the data layer, we had a method called findCountries
to fetch all the countries. If you remember, we’ve put any[]
for the response type instead of a valid one. Now let’s create a payload for it:
import * as z from "zod/v4";
// Define an object for a single country returned from the countries API
const countryPayload = z.object({
flags: z.object({
png: z.string().nonempty(),
svg: z.string().nonempty(),
alt: z.string(),
}),
name: z.object({
common: z.string().nonempty(),
official: z.string().nonempty(),
nativeName: z.record(
z.string().nonempty(),
z.object({
official: z.string().nonempty(),
common: z.string().nonempty(),
})
),
}),
});
// Define an array of countries
export const countriesPayload = z.array(countryPayload);
// Define a type out of countryPayload object using Zod's infer
export type CountryPayload = z.infer<typeof countryPayload>;
NOTE: Follow the link to get the structure for the payload
To define a payload object and a type, I use Zod. It allows us to perform runtime validation of the layer’s entities. This ensures malformed data from the third-party APIs never breaks the app. Later, when we work on the business logic, I will show you detailed validation examples.
Currently, we’re interested in the CountryPayload
type. Without Zod, it would look like this:
export type CountryPayload = {
flags: {
png: string;
svg: string;
alt: string;
};
name: {
common: string;
official: string;
nativeName: {
[key: string]: {
official: string;
common: string;
};
};
};
};
With the type created, your folder structure should look like this:
src/
├── modules/
│ └── Home/
│ └── model/
│ ├── home.dto.ts
│ └── index.ts - index file for re-exporting
Yes, it’s fine to store payload in the DTO file.
Zod is a completely optional package, and its usage depends on your project’s needs. Like I said, I’ll show you more Zod benefits later in the application layer. Meanwhile, you can specify a return type for the findCountries
like this:
findCountries() {
return this.apiService.get<CountryPayload[]>("/all?fields=name,flags");
}
DTO: Structuring Outgoing Data
DTOs, on the other hand, define a structure for the data you transfer (meaning send) to the external source. It can be a type for POST
request’s body or for an object you save into IndexedDB. DTO can also represent query params for a GET
request.
In HistoryRepository
, we have a method saveAnswer
:
saveAnswer(createHistoryRecordDto: any) {
return this.slice
.getState()
.saveAnswer({ ...createHistoryRecordDto, createdAt: Date.now() });
}
Let’s define a DTO for the createHistoryRecordDto
param:
import { z } from "zod";
export const createHistoryRecordDTO = z.object({
flagImage: z.string().nonempty(),
countryName: z.string().nonempty(),
userAnswer: z.string().nonempty(),
});
export type CreateHistoryRecordDTO = z.infer<typeof createHistoryRecordDTO>;
Put this DTO inside the model
folder of the History
module, just like we did with CountryPayload
in the previous section.
Equivalent without Zod:
export type CreateHistoryRecordDTO = {
flagImage: string;
countryName: string;
userAnswer: string;
};
This DTO consists of 3 properties:
- flagImage - URL of the flag picture, so we can display the flag near each answer.
- countryName - correct name of the country.
- userAnswer - name of the country the user entered.
Now we can specify a DTO type for the saveAnswer
method like this:
saveAnswer(createHistoryRecordDto: CreateHistoryRecordDTO) {
return this.slice
.getState()
.saveAnswer({ ...createHistoryRecordDto, createdAt: Date.now() });
}
With the DTO in place, we can send the data out safely and predictably. Now let’s look at how we shape the data inside the app using Module Types.
Module Type: Visualize Model Layer
This is the main type of the whole module. The module type holds properties to be shown on the UI. It doesn’t necessarily mean that all the properties from this type will be represented in the interface. Some properties exist for the business logic only, some can just sit there and patiently wait to be used at some point later.
Worth mentioning that sometimes module type can replace the payload, if the backend response is structured for the UI or business logic right when we receive it. If the data needs reshaping or enriching, keep the module type and the payload separate.
For example, HistoryRecord
- the module type for the History
module looks like this:
import { z } from "zod";
import { createHistoryRecordDTO } from "./history.dto";
// https://zod.dev/api#extend
export const historyRecord = createHistoryRecordDTO.extend({
createdAt: z.number(),
});
// Convert historyRecord schema into Zod array, so we can validate the response from localStorage inside the application layer
export const historyRecords = z.array(historyRecord);
export type HistoryRecord = z.infer<typeof historyRecord>;
historyRecord
schema inherits all the properties from createHistoryRecordDTO
. It also adds one more createdAt
timestamp indicating when the history record was created. HistoryRecord
type infers the structure from the schema, as usual.
Equivalent without Zod:
export type HistoryRecord = CreateHistoryRecordDTO & {
createdAt: number;
}
Use HistoryRecord
inside historySlice
to specify the type for the history:
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type { HistoryRecord } from "../model";
export type HistorySlice = {
history: HistoryRecord[]; // We add type here
saveAnswer: (result: HistoryRecord) => void; // We add type here
clearHistory: () => void;
};
export const historySlice = create<HistorySlice>()(
// History slice implementation
);
NOTE: We created
historySlice
in the data layer article.
This is how the HistoryRecord
module type can be shown on the UI:
Prop types: Provide Data or Configuration to UI
These are just regular prop types you would specify for the components in your React app. For example, we have a sidebar with a history list widget. This widget should accept the following properties:
- history - an array with all history records to display.
- loading - a boolean value indicating whether we’re fetching the data at the moment.
- error - string holding the text of the error, if any occurred.
Humbly, but enough for educational purposes. So the props type will look like this:
export type HistoryListProps = {
history: HistoryRecord[]; // an array of module types
loading: boolean;
error: string;
}
NOTE: I don’t use Zod for prop types, because this is overkill. But, of course, you can do it if you have a strong reason.
Then we provide it to the component through React.FC
:
export const HistoryList: React.FC<HistoryListProps> = ({ history, loading, error }) => {
// Widget implementation
}
Easy, isn’t it? If you read my MVVM intro, you might remember I said this in the “Application Layer” section: “usually, each hook corresponds to a specific widget from the View layer”. Suppose we have a custom hook called useHistoryList
. It fetches the history records from localStorage, handles loading and errors:
export const useHistoryList = () => {
// Hook implementation
return { history, loading, error };
};
As you can see, it returns the same set of properties that we need inside the HistoryList
widget. So we can do a little trick with our HistoryListProps
. Instead of specifying properties manually inside the type, we can infer them from the returning object of the useHistoryList
hook:
export type HistoryListProps = ReturnType<typeof useHistoryList>;
NOTE: In the code example above, we’ve used TypeScript’s utility called
ReturnType
.
That one-liner isn’t just beautiful - it automatically picks up new properties from the custom hook and adds each one fully typed to your props object.
A key downside to keep in mind is that if you change the return signature of the custom hook, it will break the type and cause TypeScript linter errors. But even with this tradeoff, the object returned by the custom hook and props type will be synced. The only place to refactor is the widget.
TIP: Remember to use utils like Omit or Pick to avoid silently carrying over properties not meant for the UI.
What’s that? What if you need an additional property that the custom hook doesn’t return? I’ve got you covered:
export type HistoryListProps = ReturnType<typeof useHistoryList> & {
customProp: string;
};
NOTE: If you’re thinking “widgets, hooks, application layer… wtf🤯,” don’t worry. You’ll get it all if you read the MVVM intro and 2 articles about application and view layers.
We had to briefly look ahead here, because it’s impossible to explain prop types in isolation without referencing the layers that use them.
Testing
NOTE: To write unit tests I’m using Vitest.
One of the reasons I tend to use Zod is that you can test model layer entities we’ve just discussed. This catches errors that TypeScript can’t, like empty strings or missing fields, which often cause runtime bugs in real-world apps. Here’s how to verify whether our DTO is correct:
import { describe, it, expect } from "vitest";
import { createHistoryRecordDTO } from "../history.dto";
describe("createHistoryRecordDTO", () => {
it("should parse valid input", () => {
const valid = {
flagImage: "flag.png",
countryName: "Ukraine",
userAnswer: "Ukraine",
};
expect(createHistoryRecordDTO.parse(valid)).toEqual(valid);
});
it("should fail for empty strings", () => {
const edge = {
flagImage: "",
countryName: "",
userAnswer: "",
};
expect(() => createHistoryRecordDTO.parse(edge)).toThrow();
});
it("should fail for missing fields", () => {
expect(() => createHistoryRecordDTO.parse({})).toThrow();
expect(() =>
createHistoryRecordDTO.parse({ flagImage: "f.png" })
).toThrow();
});
it("should fail for wrong types", () => {
expect(() =>
createHistoryRecordDTO.parse({
flagImage: 123,
countryName: true,
userAnswer: null,
})
).toThrow();
});
});
A piece of cake! No mocks, no spies, nothing. We just use regular Zod’s syntax, which we’d use in the production code.
One more test to verify countryPayload
:
import { describe, it, expect } from "vitest";
import { countriesPayload } from "../home.dto";
// Import the single country schema
const countryPayload = countriesPayload.element;
describe("countryPayload", () => {
it("should parse valid input", () => {
const valid = {
flags: {
png: "flag.png",
svg: "flag.svg",
alt: "Flag of Ukraine",
},
name: {
common: "Ukraine",
official: "Ukraine",
nativeName: {
ukr: { official: "Україна", common: "Україна" },
},
},
};
expect(countryPayload.parse(valid)).toEqual(valid);
});
it("should fail for empty strings in required fields", () => {
const invalid = {
flags: {
png: "",
svg: "",
alt: "",
},
name: {
common: "",
official: "",
nativeName: {
"": { official: "", common: "" },
},
},
};
expect(() => countryPayload.parse(invalid)).toThrow();
});
it("should fail for missing fields", () => {
expect(() => countryPayload.parse({})).toThrow();
expect(() =>
countryPayload.parse({ flags: { png: "a", svg: "b", alt: "c" } })
).toThrow();
});
it("should fail for wrong types", () => {
expect(() =>
countryPayload.parse({
flags: "not-an-object",
name: 123,
})
).toThrow();
expect(() =>
countryPayload.parse({
flags: { png: 1, svg: 2, alt: 3 },
name: { common: [], official: {}, nativeName: null },
})
).toThrow();
});
});
In both cases, we test for:
- Valid input
- Empty strings
- Missing fields
- Incorrect types
Now that you’ve seen testing examples for both DTO and payload, here’s a small challenge to test your understanding.
A little homework
Try to add other tests for other model layer entities using the example that I’ve provided. Of course, you will find all the tests in the source code if you get stuck. But I really encourage you to try on your own, because we learn the best when we take action!
Conclusion
If there’s one thing to take away from this article, it’s this: organize your types and take care of them. It’s a common place where things often get messy. Today, we’ve looked at the model layer, its entities, and how they help you organize your types and structure your code. You’ve learned:
- How to type your query bodies and responses utilizing DTOs and Payloads.
- Why we need a Module Type and how it’s used across the module.
You’ve also:
- Revised your knowledge about prop types and how MVVM’s hook-UI relation simplifies their maintenance.
- Got to know how we can test the model layer entities, so no invalid data slips into your app, causing unexpected bugs.
Now you have everything you need to build a robust model layer and keep your types consistent and predictable throughout the system. Additionally, Zod usage will make sure the data you manipulate in different parts of your app is valid.
What’s next?
Continue reading the next articles in the MVVM series:
- React MVVM Architecture with TanStack Router: View Layer
- React MVVM Architecture with TanStack Router: Application Layer
- React MVVM Architecture with TanStack Router: Orchestrator
Or go over the previous ones:
- React MVVM Architecture with TanStack Router: An Introduction
- React MVVM Architecture with TanStack Router: Data Layer
Creating content like this is not an easy ride, so I hope you’ll hit that like button and drop a comment. As usual, constructive feedback is always welcome!
📰 Read me on the platform of your choice: Substack, DEV.to
Till next time! 👋