We'll check how separating types from implementation impacts our applications from different perspectives.
The topic introduction
At the same time, other developers prefer to create files for types per each module.
I've my own thoughts about this topic and it's a good time to share them and summarize what I've learned during some research and after trying some stuff in code.
Today we'll understand the pros/cons of splitting type definitions to separate files and we'll check when it's worth to use each option.
Little spoiler - as usual in programming it depends from many factors 💨. Continue reading to spot them.
Good, old and universal rule - the separation of concerns
When you're creating a big feature you probably separate your codebase into distinct sections. The sections will be different based on the platform or technology that you working with. In languages like C#, it's common to separate interfaces (shape of the program/contract), to other files to achieve reusability, modularity, maintainability, and easy-to-read/understand codebase. This is a universal rule that can be applied to any technology.
Let's take a look at an example in which we'll implement complex button component with motives, variants, and other stuff that are usually used in web apps.
Now it's time for type definitions - at this moment we'll place them in a separate file.
Lastly, let's add some constants.
We're humans, and still, we maintain the codebase (not AI 💔), so it's good to be able to work with code faster and in a more productive way. This simple example shows, how quickly simple component code can increase its number of lines and can start to be harder to read.
Compare now what we had with this solution in which everything is added in a single file.
Imagine now holding type definitions for the big library in the same files with implementation 💥.
If you want to check separation of concerns in practice, feel free to visit this repository link with the above-mentioned example.
The circular dependency risk
Circular dependency is a situation when module A will import something from module B and vice-versa.
Unfortunately, if you store the type definitions in the same file with implementation, you increase the risk of providing circular dependency, and with that you can increase a application bundle size, or you can provide really weird/hard to spot bugs.
The real-world example can be spotted in Redux setup boilerplate.
Firstly, it looks really weird (for me of course). Type declarations are hoisted, but anyway, it looks like we're using two different languages at the same time. It makes it harder to spot what is going on in this file.
Secondly, imagine that we want to create our custom middleware that listens for dispatched actions, and if it contains pending in action type we want to log the payload of an action. The middleware type from redux takes a type of state to be passed.
If we want to attach this middleware to our store, we need to import it and pass it to a configureStore function.
As you saw creating circular dependency is really easy. We can avoid such a situation by storing type definitions in a separate file.
Now let's change in both places import statements.
Lastly, little change in the middleware file.
So, as you saw storing type defs in separate files reduces the risk of having circular dependency between modules.
There are tools that allows us to track circular dependencies. One of them is the nx. If you are interested, read the Overview of the main functionalities of the NX tool and Dream stack for React dev articles.
The impact on performance
Rebuild time 👍
When you modify a TypeScript file, the TypeScript compiler needs to recompile that file and any files that depend on it. If your type definitions are centralized in a single file and many other files depend on it, a change to that central file can trigger the recompilation of a large portion of your codebase.
In contrast, if type definitions are separated into smaller, more focused files, changes to one type file are less likely to trigger unnecessary recompilations in unrelated parts of the codebase.
When type definitions are in separate files, the build tool can parallelize the compilation of these files, potentially reducing the overall build time.
Initial build time ❗
If you have an excessive number of small type definition files, the initial load time for your development environment (e.g., in an IDE) might be longer because it needs to load and parse more files.
Note about performance
Before you start any changes in your codebase it's good to have consistent measurements of build times. You have tons of options, but I think the best one is just creating a small script that will create a file in each build (on pull request) with a snapshot of the time required to build dedicated application or module.
Don't try to fix problems that don't exist!
Modeling aspect - types first approach
Before you start to work on any code, you should try to think about the shape of API, structure and dependencies. By splitting types from implementation, you are doing the modeling process - types first approach.
Because of that your API will be more consistent and you'll be able to prepare multiple implementations. They will be independent, but still will have the same shape. Later, it's much easier to choose the best one or replace the other if it contains a bug/has dramatically bad performance.
Look at following implementation of multiple variants of sum function, with separated contract:
The implementation process of multiple sum variants
The use cases for each option
The general rule is: "Split your types if you need them in other places".
Application core, domain models
Reusable, presentational components
Multiple variants of something
Variants are great use case for separate types
Modeling state and actions
Avoid doing split if you don't need types in other places.
If you have concerns about which option to use in the dedicated scenario, inspire yourself with the following repository.
Let's summarize what we learned
Topics like that are always hard because they cannot be explained in a 0/1 way. So what we have learned today?
Simple split may increase code readability, especially if your type definitions are large.
Splitting types into separate files may remove problems with circular dependencies in your code base, but this problem occurs in really rare situations, so it's not a big deal.
Types in separate files will reduce the code rebuild time due to parallelism and caching, but may increase the initial build time.
We shouldn't do any premature optimizations - measure first and then try to improve build times (if it is a real problem!).
Splitting out type definitions allows us to design a nice and developer-friendly API, and it's part of the software modeling process that may have a positive impact on code quality.
It's worth splitting types for domain models, libraries, reusable/presentation components, when you want to have multiple variants of implementation, but when you developing small, internal code, it's better to keep types in the same file - there is no need to reuse them in other places.
So if you ask me - should I separate definitions from implementation? If it will be a C# ecosystem I would say - definitely yes, but it's TypeScript, and my answer is: do it if you are writing a separate library, creating reusable components or you're modeling your application domain, but for other cases, I think it's not needed...
Don't try to follow all programming rules like religion.