DEV Community

Cover image for How to structure Typescript project
Udayan Maurya
Udayan Maurya

Posted on • Updated on

How to structure Typescript project

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 Employees list page
    • API to fetch the data: GET /employees
    • Response schema
[
  {
    id: number,
    firstName: string,
    lastName: string,
    role: string,
    department: string
  },
  ...
]
Enter fullscreen mode Exit fullscreen mode
  • Page 2: Employee profile details page
    Employee Profile page

    • API to fetch the data: GET /employees/{empId}
    • Response schema
{
  id: number,
  firstName: string,
  lastName: string,
  role: string,
  department: string,
  emailAddress: string,
  phoneNumber: string,
  address: string,
  manager: string,
  favoritePokemon: string
}
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

Problems with this approach

  1. Extending API responses results in a document DB like schema design of the application data.
  2. The schema does not provide much value on client side.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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}/>
Enter fullscreen mode Exit fullscreen mode

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}/>
Enter fullscreen mode Exit fullscreen mode

To this change FavoritePokemon component will not throw any type error. However, at runtime FavoritePokemon will render something like:

pikachusquirtle

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

Machine design

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

Types around machine

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}`);
Enter fullscreen mode Exit fullscreen mode

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}/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 both string and string[].
  • 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:

  1. REST APIs remain independent, the way they are meant to be!
  2. Easier to follow code. Type definitions won't result in rabbit holes.
  3. No need to manage data relations on client side. Client side code was never meant to do that.
  4. Better type safety and more meaningful type errors!

Title image by Joshua Woroniecki from Pixabay

Top comments (0)