We added TypeScript (TS) to our React + Ruby on Rails app about 4 months ago now. We have reached a decent tipping point where I'm not the only one who understands how to use TypeScript and other devs are actively converting non-TS files over to TS without much guidance.
One hurdle we've recently faced, is that as we add more types and shared types across multiple directories in our frontend, knowing WHERE a type lives and IF a type already exists has become really challenging. We've run into almost virtual duplicates of types multiple times now.
I said, "I've had enough! This is supposed to be helping us be less confused - not add to our confusion."
How can more structure be added to our storage of types so that it's easier for all of us to know where to check if a type exists already or not?
With some fiddling around, a little reading, and a little battling of our linter and we came to a pretty great solution I'm happy to document here.
The Problem
For some context, we have a frontend that's broken up into three "apps" and then shared helper files kind of like this:
bakery/
├── cookies/
│ ├── Cookie.tsx
│ ├── Biscuit.tsx
│ ├── lib/api.tsx
├── cake/
│ ├── Cake.tsx
│ ├── BirthdayCake.tsx
│ ├── lib/api.tsx
├── pudding/
│ ├── Pudding.tsx
│ ├── lib/api.tsx
└── shared/
└── types/
So what has organically happened (and what is a little hard to show with his very simple example) is that components inside /cookies
for example have some of their types locally defined since only those components need access to those types. So you might have an import statement like:
// bakery/cookies/SomeComponent.tsx
import { ChocolateChipType } from 'bakery/cookies/Cookie'
But what if you wanted a Chocolate Chip Cake? Then you'd have to access the Chocolate Chip type from the cookie directory and that's where things start to get really messy.
For us, as soon as we started to leverage shared types and generics, our organization and structure went out the window.
So, a new day began and I was determined to figure out how to make our types more easily shareable while also reducing the places you had to check for where the type might be defined.
The Solution
Option 1: Namespacing (deprecated)
I'll be honest - this was the first solution I came up with. I liked it - but our linter hated and informed me was a deprecated method 😅 .
We use "react-scripts": "3.4.1",
.
🔖 Namespacing and Module TypeScript Documentation
To me, namespacing is a way of grouping similar things together under one umbrella name. So you could have a namespace of Cookies
and then nested types of ChocolateChip
, Sugar
, Overcooked
, etc.
A file using this method might look like this:
// bakery/cookie/types.ts
export namespace Cookies {
export interface ChocolateChip { /* ... */ }
export class Sugar { /* ... */ }
}
Then, if I want to use the ChocolateChip type, I could import and use it like this:
// bakery/cookies/Oven.tsx
// import full namespace
import { Cookies } from 'bakery/cookie/types'
// access specific type via dot notation
function CookieOven({ cookie }: Cookies.ChocolateChip) {
return (...)
}
This method has the benefit of great autocomplete for your IDE - as soon as you type Cookies.
you should see a list of all the types defined under that namespace.
Again - I personally liked how this looked, how it worked, and it made sense to me. BUT my linter did not like it and I had to start over with a different idea.
Depending on the version of eslint you're using, you might be able to use this method, but honestly I wouldn't recommend it since you'd have to upgrade and adapt eventually.
Option 2: ES6 Modules
This should look familiar to most of you since it's probably the way you import React to each of your components:
import * as React from 'react'
While it's nothing revolutionary, the way we leveraged ES6 modules are what made this solution make sense for us.
So, back to our type definition file, we remove the namespace
and individually export all of our types and interfaces:
// bakery/cookie/types.ts
export interface ChocolateChip { /* ... */ }
export class Sugar { /* ... */ }
Because of the structure of our app, we actually have multiple segmented type files in one directory. That looks something like this:
bakery/
├── cookies/
│ ├── Cookie.tsx // No more types defined here
│ ├── Biscuit.tsx
│ ├── lib/api.tsx
├── cake/
│ ├── Cake.tsx
│ ├── BirthdayCake.tsx
│ ├── lib/api.tsx
├── pudding/
│ ├── Pudding.tsx
│ ├── lib/api.tsx
└── shared/
├── types/ // all types defined here
├── cookie.ts
├── cake.ts
└── pudding.ts
I don't know about you - but when you have an app that uses a lot of modular components that can be put together in a variety of ways for different use cases, you start to get import fatigue.
Even once I moved the types into the shared/types
directory, I was still having to import types across multiple files like this:
// bakery/cookie/SomeComponent.tsx
import { ChocolateChip } from 'bakery/shared/types/cookie'
import { Tiramisu, DeathByChocolate } from 'bakery/shared/types/cake'
Not terrible - and this works! - but pretty annoying if you want to use types from multiple modules in one file.
To try to get down to one import statement, you can add a default export file: index.tsx
and then the magic starts to happen. So let's add that file:
bakery/
├── cookies/
├── cake/
├── pudding/
└── shared/
├── types/
├── cookie.ts
├── cake.ts
├── index.ts // This file is important!
└── pudding.ts
And update it to be our main source of exporting all types:
// bakery/shared/types/index.ts
import * as Cookie from './cookie'
import * as Cake from './cake'
import * as Pudding from './pudding'
export { Cookie, Cake, Pudding }
So this is saying import everything from those files under the alias as XYZ
and then export them as an object literal. The functional behavior of that export statement is similar to:
export {
Cookie: Cookie, // key is "Cookie" and value is the exported module of Cookie
Cake: Cake,
Pudding: Pudding
}
So now! Let's go check out how that changes our import statement in our component:
// bakery/cookie/SomeComponent.tsx
// OLD WAY
// import { ChocolateChip } from 'bakery/shared/types/cookie'
// import { Tiramisu, DeathByChocolate } from 'bakery/shared/types/cake'
// NEW WAY
import { Cookie, Cake } from 'bakery/shared/types`
🎉 That's it! We can still access individual interfaces from those imports like Cookie.ChocolateChip
. But now, we also get autocomplete for the import statement - so you know which modules you have access to while you're importing.
It's not very exciting or even a new idea in the world of JavaScript - but let me tell you - this small change has saved us so much time, reduced our cases of duplication, and made it really obvious WHERE our type definitions should live and how we manage them.
Top comments (0)