Intro
We'll check how separating types from implementation impacts our applications from different perspectives.
The topic introduction
I've seen a lot of different opinions about splitting out the type definitions from run time code (our application JavaScript code). A lot of people prefer to have types in the same file - just before the implementation.
Loading
At the same time, other developers prefer to create files for types per each module.
Loading
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.
Loading
Now it's time for type definitions - at this moment we'll place them in a separate file.
Loading
Lastly, let's add some constants.
Loading
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.
Loading
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.
Loading
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.
Loading
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.
Loading
If we want to attach this middleware to our store, we need to import it and pass it to a configureStore function.
Loading
As you saw creating circular dependency is really easy. We can avoid such a situation by storing type definitions in a separate file.
Loading
Now let's change in both places import statements.
Loading
Lastly, little change in the middleware file.
Loading
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.
Parallelism 👍
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:
Loading
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
Loading
Reusable, presentational components
Loading
Multiple variants of something
Loading
Variants are great use case for separate types
Modeling state and actions
Loading
Reusable logic
Loading
Standalone libraries
Loading
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.
Comments
Add your honest opinion about this article and help us improve the content.
10/10