Typescript is an amazing tool to provide static type safety to you large Javascript applications. Type definitions can act as documentation for new team members, provide useful hints about what can break the software, and have an addictive code completion!
However, Typescript is no magic wand and to derive all the aforementioned benefits Typescript project should be structured properly. Otherwise, Typescript can be nightmarish. More like: Where is this TS error coming from !?, why is this not assignable!!, man TS is a headache this would be shipped by now with just JS 🤦
In this blog I'll describe how to structure Typescript project for a RESTful client-side web application for:
- Readable and maintainable code
- Pivotable codebase
- True type safety!
We will use a sample HR application to discuss all the examples.
- Application has 2 pages
- Page 1: List of all employees
- API to fetch the data: GET
/employees
- Response schema ```ts
- API to fetch the data: GET
[
{
id: number,
firstName: string,
lastName: string,
role: string,
department: string
},
...
]
- Page 2: Employee profile details page
![Employee Profile page ](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hrcm0x2rnrzwl5wzxl49.png)
- API to fetch the data: GET `/employees/{empId}`
- Response schema
```ts
{
id: number,
firstName: string,
lastName: string,
role: string,
department: string,
emailAddress: string,
phoneNumber: string,
address: string,
manager: string,
favoritePokemon: string
}
Common Typescript problems
Problem 1: Schema mapping
Unfortunately, many large projects suffer from this problem.
In a client side application it is common to describe the response schema as follows:
export interface EmployeeShortResponse {
id: number,
firstName: string,
lastName: string,
role: string,
department: string
}
// Bad: Equivalent to NoSql schema design
export interface EmployeeLongResponse extends EmployeeShortResponse {
emailAddress: string,
phoneNumber: string,
address: string,
manager: string,
favoritePokemon: string
}
export const employeesAPI = (): Promise<EmployeeShortResponse[]> =>
fetch("/employees");
export const employeesDetailsAPI = (
id: number
): Promise<EmployeeLongResponse> => fetch(`/employees/${id}`);
Problems with this approach
- Extending API responses results in a document DB like schema design of the application data.
- The schema does not provide much value on client side.
- Schema creates synthetic relationship between independent REST APIs.
For instance if employeesAPI
is updated to return one more property: region
.
The EmployeeShortResponse
will update like:
export interface EmployeeShortResponse {
id: number,
firstName: string,
lastName: string,
role: string,
department: string,
+ region: string
}
This in turn will automatically update the response type of employeesDetailsAPI
while there is no guarantee that response of employeesDetailsAPI
has changed 😳
Mapping relations of application data on client side results in confusing, hard to track and unhelpful type errors. Front end application has no means to enforce the relations in application data. For best separation of concerns application data relations should be managed only at one place in the system: database of the application.
Problem 2: Expanding API response type beyond API
Let's say, in our example, we have a component to display "Favorite Pokemon" defined like
// EmployeeLongResponse defined as in Problem 1
type FavoritePokemonProps = {pokemon: EmployeeLongResponse['favoritePokemon']};
const FavoritePokemon = ({pokemon}: FavoritePokemonProps) => {
if (pokemon.length > 0) {
return <p>Favorite Pokemon: {pokemon}</p>
}
return null;
}
// Example usage:
// apiResponse.favoritePokemon = 'pikachu'
<FavoritePokemon pokemon={apiResponse.favoritePokemon}/>
But really, who has only one favorite pokemon? So the application requirements change to return list of favorite pokemons in API response for GET /employees/{empId}
. Response schema updates like:
{
id: number,
firstName: string,
lastName: string,
role: string,
department: string,
emailAddress: string,
phoneNumber: string,
address: string,
manager: string,
- favoritePokemon: string
+ favoritePokemon: string[]
}
// Example usage:
// New apiResponse.favoritePokemon = ['pikachu', 'squirtle']
<FavoritePokemon pokemon={apiResponse.favoritePokemon}/>
To this change FavoritePokemon
component will not throw any type error. However, at runtime FavoritePokemon
will render something like:
Now that is a shock no one wants ⚡💦😵💫
How to structure Typescript code
Let's first breakdown the purpose of client side web application. Client side application needs to
- Receive data from server
- Consume the data and display meaningful UI
- Manage state of user interactions
- Capture permanent changes to application data and send it to server
And the purpose of typescript is to establish a contract in the program which determines the set of values that can be assigned to a variable. PS highly recommended read TypeScript and Set Theory.
Most important thing, from code design perspective, is to identify what are appropriate places to establish this contract.
Few principles to keep in mind to get most out of typescript:
- Follow the flow of data across application
- Keep isolated pisces of code isolated
- Typescript helps in gluing the isolated pisces, that is inform users when they try to flow data across incompatible pisces
Problem 1: [Solution] No NoSQL on Frontend
Through typescript code we should not emphasize relationships in application data. REST APIs are meant to be independent and we should keep them that way.
Following this principle the example of HR application will update like this:
export interface EmployeeShortResponse {
id: number,
firstName: string,
lastName: string,
role: string,
department: string
}
// Redundantly define all response types!
export interface EmployeeLongResponse {
id: number,
firstName: string,
lastName: string,
role: string,
department: string,
emailAddress: string,
phoneNumber: string,
address: string,
manager: string,
favoritePokemon: string
}
export const employeesAPI = (): Promise<EmployeeShortResponse[]> =>
fetch("/employees");
export const employeesDetailsAPI = (
id: number
): Promise<EmployeeLongResponse> => fetch(`/employees/${id}`);
Now there won't be any surprises in working with response of one API while other API's responses may change. Though this approach involves redundant typing, it adheres to the purpose of client side application. Generally, redundancy in code is not a bad thing if it results in more useful abstractions. Refer AHA Programming by Kent C. Dodds.
Problem 2: [Solution] Isolated pisces isolated
Flow of data on client side: Once data is received from server it will flow through state, props and utility functions. All these artifacts should be typed independently. So that whenever they are used with incompatible data typescript can inform us: Type safety!
So now we will define FavoritePokemon
component like:
// EmployeeLongResponse defined as in Problem 1
type FavoritePokemonProps = {pokemon: string};
const FavoritePokemon = ({pokemon}: FavoritePokemonProps) => {
if (pokemon.length > 0) {
return <p>Favorite Pokemon: {pokemon}</p>
}
return null;
}
// Example usage:
// apiResponse.favoritePokemon = 'pikachu'
<FavoritePokemon pokemon={apiResponse.favoritePokemon}/>
Now as the /employees/{empId}
API changes to respond with a list of pokemons:
{
id: number,
firstName: string,
lastName: string,
role: string,
department: string,
emailAddress: string,
phoneNumber: string,
address: string,
manager: string,
- favoritePokemon: string
+ favoritePokemon: string[]
}
// Example usage:
// New apiResponse.favoritePokemon = ['pikachu', 'squirtle']
<FavoritePokemon pokemon={apiResponse.favoritePokemon}/> // Error
Typescript will yell at us right away with error: 'string[]' is not assignable to type 'string'
Now as a developer we can decide:
- Do we want to expand
FavoritePokemon
so it work with bothstring
andstring[]
. - Or do we want to create a totally different component display multiple pokemons.
Conclusion
Writing type definitions along the flow of data in application results in following benefits:
- REST APIs remain independent, the way they are meant to be!
- Easier to follow code. Type definitions won't result in rabbit holes.
- No need to manage data relations on client side. Client side code was never meant to do that.
- Better type safety and more meaningful type errors!
Title image by Joshua Woroniecki from Pixabay
Top comments (0)