Devblog #2 - Splitting up the codebase
Introductionโ
I'm a huge fan of TypeScript
. I think it must have been around 2015. When I also found out that the project is led by Anders Hejlsberg it solidified my quick judgement only further ๐
Anders co-designd several programming languages, among them C#
which I highly like since it offers a great developer experience and it's just a pleasure to work with. So it was a no brainer to also follow this project and I've been happy working with it ever since.
So whenever there's a new TypeScript
release with a new setting that would improve the type safety further, I'm eager to try it out.
So I did with the noUncheckedIndexedAccess
setting which was added in 4.1.
Status Quoโ
While the project is already making use of the strict
compile setting, noUncheckedIndexedAccess
needs to be added manually as it's not part of the strict
rule family.
noUncheckedIndexedAccess
โ
In short this setting makes accessing items in an array stricter.
Even with the strict
setting, the following code will not error even though it should!.
let names: string[] = []; //empty array
let first = names[0];
first.toUpperCase(); //no error!
In this case, first
is actually undefined
so calling first.toUpperCase()
will result in a runtime exception, not good.
By turning on noUncheckedIndexedAccess
this particular code will now error as expected:
let names: string[] = [];
let first = names[0];
first.toUpperCase(); //Object is possibly 'undefined'.
So to access this variable properly it now needs a dedicated check:
let names: string[] = [];
let first = names[0];
if (first) first.toUpperCase(); //all good now
Since this setting can be rather noisy it's not included in the default settings for strict
;
First test runโ
So lets turn it on for our code:
54 errors, quite some.
But if we look closely most of them are actually coming from the unit tests like:
tests/nameVisitor.test.ts:158:16 - error TS2532: Object is possibly 'undefined'.
158 expect(rest[0].license).toBe("ISC");
~~~~~~~
While in this case I could satifisy the compiler quite nicely with optional chaining:
expect(rest[0].license)!.toBe("ISC");
More work would be required if destructuring is used:
tests/nameVisitor.test.ts:153:18 - error TS2339: Property 'license' does not exist on type '{ license: string; names: string[]; } | undefined'.
153 const [{ license, names }, ...rest] = new LicenseUtilities(p).licensesByGroup;
~~~~~~~
Since I didn't feel like this setting is particularily useful for unit tests where you have defined inputs and you're looking for the expected output, I thought maybe there's a way I can apply noUncheckedIndexedAccess
only to the actual source code. Turns out there's a way!
Current setupโ
The project setup is fairly simple, there is a single tsconfig.json
which includes the src
and tests
folders.
But that also means that compile settings are shared.
And just as you can import code from src
into tests
for your unit tests, you can do the opposite as well, which doesn't really make sense.
One naive solution would be to create 2 TypeScript
projects one in the src
folder and one in tests
but that also means to start the TypeScript
compiler 2x ๐ฌ
Luckily TypeScript
already provides a solution to that problem, namely Project References.
Project Referencesโ
With project references I can selective enable different compile settings for different folders of my project.
This allows me to setup src
in a way that I cannot accidentally import anything from tests
, while tests
itself can import stuff from src
.
If the unit tests are compiled, it will look where the import is from and apply different compile settings, anything that is imported from the src
folder will be compiled with noUncheckedIndexedAccess
while everything under tests
will be compiled like always.
Best of it is that I don't need 2 separate TypeScript
processes for it, I still only need to start the TypeScript
compiler once.
I just have to tell TypeScript
that I'm using project references:
#old
tsc --watch
#new
tsc --watch --build
Conversionโ
The actual code conversion was straight forward, adding missing undefined
checks. But it also surfaced some questionable design decisions that I did in the past.
E.g. there's functionality to return the most referenced packages in the dependency tree. But the data structure was quite weird:
type MostReferred = [string, number][];
An array of [string, number]
tuples, where string
is the package name and number
is the reference count, it would look like this:
let mostReferred = [
["react", 3],
["chalk", 3],
["lodash", 3]
];
But the number, which specifies the reference count would be the same for all tuples in the array, so there is really no need to specify it for every tuple. Furthermore I used to use the reference count from the very first entry as the displayed value like:
const [, count] = mostReferred[0];
Which of course now errors, because it rightfully might be undefined
if the array is empty aka a package doesn't have any dependencies. No dependencies = no referenced packages.
So instead of fixing this error by checking yet again for undefined
I decided to change the data structure altogether, so I don't need to destructure an array:
//refactored data structure
export interface IMostReferred {
pkgs: string[];
count: number;
}
Now I can get the count by simply doing mostReferred.count
and all the packages are in a simple string
array ๐.
Final Setupโ
So how did I enable different compile settings for different folders in the project?
Quite simple actually:
Top level tsconfig.json
โ
The first change was to convert the existing tsconfig.json
to a "top level tsconfig", that simply points to other tsconfigs:
{
"compilerOptions": {
//...same compiler options as before
},
"include": [],
"references": [
{ "path": "./src" },
{ "path": "./tests" }
]
}
Note the empty include
. This tsconfig doesn't compile anything, it just references other tsconfig.json
locations. The path
needs to point to a tsconfig.json
file or a folder where it then looks for a tsconfig.json
(like above).
./src/tsconfig.json
โ
In the src
folder I created a new tsconfig.json
as follows:
{
"extends": "..",
"compilerOptions": {
"composite": true,
"noUncheckedIndexedAccess": true
},
"include": ["./**/*.ts"]
}
extends
tells the compiler to take the compilerOptions
from the top level tsconfig.json
that is, the same compile settings that have been enabled so far and merge them with the compilerOptions
from this tsconfig.json
.
composite
tells TypeScript
that this is a project reference and is a requirement to set.
Finally here we enable noUncheckedIndexedAccess
and tell via include
to apply these settings for every .ts
file that is in a subfolder.
./tests/tsconfig.json
โ
We do the same again for the tests
folder:
{
"extends": "..",
"compilerOptions": {
"composite": true
},
"include": ["./**/*.ts"],
"references": [
{ "path": "../src" }
]
}
The only difference here is that we reference the tsconfig.json
in the src
folder, otherwise we wouldn't be able to import code from there.
Compilingโ
Finally to compile the project you need to invoke TypeScript (on the top level, like before) with the -b
or --build
option as it is now using project references.
# before
tsc
# after
tsc --build
And that was all that was needed to compile files with different settings in the same project.
Everything still works as it used to ๐:
- Still 1 command to compile everything
- Changes in
src
still highlight errors intests
- Codelens still shows usage in
src
andtests
- No more recompile for
tests
when changing internals insrc
Overall pretty smooth change given that I never worked with project references before and therefore expected a bumpier road or at least some compromises but everything works as before. Kudos to the TypeScript team.