Skip to main content
  1. Posts/

TypeScript: Organization with `@` Directories

·1319 words·7 mins
Project Architecture TypeScript Opinion
Tim Goshinski
Author
Tim Goshinski
I code and I know (some) things

TL:DR I generally have some variation of this set of folders that I add to all of my TypeScript projects:

src/
├── @enums/                 <- project-wide Enumerations
│   ├── AsyncStates.ts        <- (described below)
│   └── HttpStatusCodes.ts    <- (described below)
├── @interfaces/            <- project-wide Interfaces
├── @mocks/                 <- mocks used in local dev and tests
└── @types/                 <- project-wide Types
    └── AsyncStatus.ts        <- (described below)

As a longtime JavaScript developer I admit that it took me a long while to warm up to TypeScript. However, after fully embracing TypeScript the one thing that still irritated me was a seemingly growing amount of non-JavaScript related files bloating up my project folders - ie. the superset part of TypeScript: types, interfaces, and enumerations. When I am looking at a folder with more than six (6) *.ts files it is nice to know that I am looking at app logic and/or unit tests.

I believe the first time I noticed an at folder it was something along the lines of @models in one of the company’s architects seed projects. I thought the @ prefix was a novel idea being legal in JavaScript/TypeScript and it really called out to me that this was a special folder. At the time our stable team was working on a really interface-heavy React front-end and some of the service and component folders were growing quite large with bespoke interfaces and types mixed in with the business logic and (too few) unit tests.

One weekend I decided to create a spike branch and move as many of the interfaces and types as I could into @types and @interfaces folders. Not only did it declutter some service and component folders I also managed to find a couple of places we had duplicates and more than one error. This pattern proved to be very popular with the team and we expanded the idea to include an @enumerations folder to house some enumerations we had created to lend meaning to some of the magic numbers and string values peppered around the project. After a few years using this pattern I have not had a team yet that disliked, or really had any problem with it.

Some more detail and examples of my reasoning follows.

@enums (or @enumerations)
#

Here we give meaning to groups of numbers or strings, say your backend had a numeric field AddressType - what does that 1 in the field really mean? By creating an AddressTypes enumeration we no longer have to remember 3 means “Business” or that 5 stands for “Cabin by the Lake”. For a real-world example this particular enumeration helps me keep a lot of magic numbers out of my React front-ends and NodeJS backends:

@enums/HttpStatusCodes.ts

/* eslint-disable no-magic-numbers */
enum HttpStatusCodes {
  Ok = 200,
  Created,
  Accepted,
  NoContent = 204,
  MovedPermanently = 301,
  Redirect = 302,
  BadRequest = 400,
  Unauthorized,
  Forbidden = 403,
  NotFound,
  InternalServerError = 500,
  NotImplemented,
  BadGateway,
}

export default HttpStatusCodes;

@interfaces
#

The majority of the interface definitions I put here describe the DTO’s coming from my services. Since I come from a C# background I like to preface all of my interface names with “I”, as in IUsersResponse, IWidget, etc. - I find that it helps me differentiate interfaces at a glance from types or classes.

example:

export default interface IUser {
  id: number;
  name: string;
  username: string;
  address: {
    street: string;
    suite: string;
    city: string;
    zipcode: string;
    geo: {
      lat: number;
      lng: number;
    };
  };
  phone: string;
  website: string;
  company: {
    name: string;
    catchPhrase: string;
    bs: string;
  };
}

@types
#

Really just what it says, types that apply project wide. I feel the best example is a combination of an enumeration and type that I find handy when creating Redux Toolkit slices that utilize thunks:

@types/AsyncStatus.ts

import AsyncStates from '../@enums/AsyncStates';

type AsyncStatus =
  | AsyncStates.Idle
  | AsyncStates.Pending
  | AsyncStates.Success
  | AsyncStates.Fail;

export default AsyncStatus;

@enums/AsyncStates.ts

enum AsyncStates {
  Idle = 'IDLE',
  Pending = 'PENDING',
  Success = 'SUCCESS',
  Fail = 'FAIL',
}

export default AsyncStates;

Work together like so to better describe the intent of the values being used:

store/slices/user.ts

import { fetchUsers } from '../../services/user/UsersApi';

// NOTE: not all interfaces end up in the global folder if they make
//       more sense at a granular level
export interface IUserSlice {
  current: IUser | null;
  users: Array<IUser>;
  status: AsyncStatus;
  error: string | null;
}

export const initialState: IUserSlice = {
  current: null,
  users: [],
  status: AsyncStates.Idle,
  error: null,
};

export const user = createSlice({
  name: 'user',
  initialState,
  reducers: {
    clearCurrentUser: (state: IUserSlice) => {
      state.current = null;
    },
    setCurrentUser: (state: IUserSlice, action: { type: string; payload: number }) => {
      const user = state.users.find(x => x.id === action.payload);

      if (user) {
        state.current = user;
        state.error = null;
      } else {
        state.current = null;
        state.error = 'user not found';
      }
    },
  },
  extraReducers: builder => {
    builder.addCase(loadUsers.pending, (state: IUserSlice) => {
      state.status = AsyncStates.Pending;
    });

    builder.addCase(
      loadUsers.fulfilled,
      (state: IUserSlice, { payload }: PayloadAction<Array<IUser> | undefined>) => {
        state.status = AsyncStates.Success;
        state.users = payload ? (payload as Array<IUser>) : [];
        // or you could make it an additive operation
        // state.users = [...state.users, ...(action.payload as Array<IUser>)];
      },
    );

    builder.addCase(loadUsers.rejected, (state: IUserSlice, action: any) => {
      state.status = AsyncStates.Fail;
      state.error = action.payload as string;
      state.users = [];
    });
  },
});

Bonus: @mocks
#

Since some of the response signatures from the services can be quite complex we found it convenient to collect all of our mocks in a single location to be easily reused across multiple unit tests. It is also handy for the rest of the team to not have to reinvent the wheel each time they are writing new code relating to the previously mocked values.

Another bonus that came out of creating these mocks is we found that it allowed us to develop service and component code in parallel with the backend. The front-end developer collaborates with the backend dev to get the proposed shape of the DTO that will come out of the API. From there it is simply a matter of defining the interface for said data and developing a mock return value in the @mocks directory. To simulate the interaction for your application you can pipe mocked values through the service code until the backend is ready for you to pull the live version. And triple-bonus you now have valid mocks for all unit tests around the service consumption.

example:

import IBlock from '../@interfaces/IBlock';

const mockChain: Array<IBlock> = [
  {
    timestamp: 1,
    lastHash: '-----',
    hash: '=====',
    data: [],
    difficulty: 3,
    nonce: 0,
  },
  {
    timestamp: 1664912323208,
    lastHash: '=====',
    hash: '12d331b40d8ef653827c9e43502c3ee73232038be01937f1cd8328fe699a85a8',
    data: ['lookit', 'da', 'birdie'],
    difficulty: 2,
    nonce: 8,
  },
  {
    timestamp: 1664912364866,
    lastHash: '12d331b40d8ef653827c9e43502c3ee73232038be01937f1cd8328fe699a85a8',
    hash: '458059ca76b703b0be6d7f30b80fb44ad3b8436348032f59efcf5056930b2b38',
    data: ['data', "come'n", "get'ur", 'data'],
    difficulty: 1,
    nonce: 1,
  },
  {
    timestamp: 1664912386011,
    lastHash: '458059ca76b703b0be6d7f30b80fb44ad3b8436348032f59efcf5056930b2b38',
    hash: '15c3351282a1c3a5744e101c005243a3df0b9ce781c3a76d55f850c28fd3dbdd',
    data: ['why', 'not', 'both?'],
    difficulty: 1,
    nonce: 2,
  },
  {
    timestamp: 1664912416611,
    lastHash: '15c3351282a1c3a5744e101c005243a3df0b9ce781c3a76d55f850c28fd3dbdd',
    hash: '2f0211da93f4ef426f4588c1ba36db5cdbc6802e8ef8156494f9507af253da74',
    data: ['not', 'your', 'friend', 'buddy'],
    difficulty: 1,
    nonce: 4,
  },
  {
    timestamp: 1664912432611,
    lastHash: '2f0211da93f4ef426f4588c1ba36db5cdbc6802e8ef8156494f9507af253da74',
    hash: '12e5596c6f124fc0989a2f61cc79b48b5e16a8ce5abfb43ad47d8a531c406d1f',
    data: ['not', 'your', 'buddy', 'amigo'],
    difficulty: 1,
    nonce: 1,
  },
];

export default mockChain;

in use:

import mockChain from '../../../@mocks/blockchain';
import * as chainApi from '../../services/ChainApi';
jest.mock('../../services/ChainApi');

describe('components / App', () => {
  it('should match the snapshot', async () => {
    (chainApi.fetchBlocks as jest.Mock).mockResolvedValueOnce(mockChain);

    const component = render(<App />);

    expect(component.container.firstChild).toMatchSnapshot();
  });
});

Which may also be easily reused in the negative test:

// just making something up here as the BlockChain class
//   itself actually prevents tampered chains
const badChain = [...mockChain];
badChain[1].lastHash = '1am4b4dh4xx0r';

(chainApi.fetchBlocks as jest.Mock).mockResolvedValueOnce(badChain);

I hope that maybe this can help some of you reduce the cognitive load of your larger TypeScript projects. If you have a variation of this that works even better feel free to drop me a line, I would be grateful to hear about it. Huge thanks go to former co-worker and amazing front-end architect Brian Olson for forcing me to use TypeScript until I learned to enjoy it.

project root
my average project root