Modular type-system in TypeScript
_Writing a type system with TypeScript can feel like programming, even if eventually it doesn’t even reach your distributed program code. But, a good type-system will improve it as it evolves. Like unit tests, types will keep the bugs away and improve your code maintainability and readability.
- It checks your code and gives instant feedback.
- It helps understand the code faster.
- It makes it easier to write new code or modify existing one.
One could say that good automated tests can replace types. I could agree with that, but as the system grows- it becomes harder to write high-quality tests, and they take longer to run. Types will give you instant feedback while you code and make it easier to understand what the code does.
To me, it also separates the thoughts about the schema and the logic, which helps me think more clearly about what I am writing.
Let’s dig in!
type AnimalBasicType = "BIRD" | "REPTILE";
type AnimalType = "TURTLE" | "SNAKE" | "CHICKEN" | "EAGLE";
interface Attributes {
amountOfLegs: number;
canFly: boolean;
length: number;
}
interface Animal {
name: string;
birthDate: Date;
countries: string[];
basicType: AnimalBasicType;
type: AnimalType;
attributes: Attributes;
}
Nothing special here, we have some simple union types
for the AnimalBasicType
and AnimalType
strings and two interfaces.
What is good about this type-system? It is simple, and easy to understand.
What can be improved? It is not precise enough, for example, the following object is valid here:
{
name: 'John',
birthDate: new Date(1995, 1, 1),
countries: ['Colombia', 'Austraia', 'Japan'], // How can a reptile be in so many places?
basicType: 'REPTILE',
type: 'CHICKEN', // Chickens are not reptiles!
attributes: {
amountOfLegs: 5, // A chicken with 5 legs? What?
length: 2000,
canFly: true // Chickens can not fly...
},
}
Ok, so let’s make it more precise. We can do it by splitting the types into a more modular system:
type AnimalBasicType = "BIRD" | "REPTILE";
type AnimalTypeReptile = "TURTLE" | "SNAKE";
type AnimalTypeBird = "CHICKEN" | "EAGLE";
interface AttributesBase {
length: number;
}
interface AttributesBird extends AttributesBase {
amountOfLegs: 2;
canFly: boolean;
}
interface AttributesReptile extends AttributesBase {
amountOfLegs: 4 | 0;
canFly: false;
}
interface AnimalBase {
name: string;
birthDate: Date;
}
interface AnimalBird extends AnimalBase {
countries: string[];
basicType: "BIRD";
type: AnimalTypeBird;
attributes: AttributesBird;
}
interface AnimalReptile extends AnimalBase {
countries: [string];
basicType: "REPTILE";
type: AnimalTypeReptile;
attributes: AttributesReptile;
}
type Animal = AnimalReptile | AnimalBird;
Nice! That is more precise, and we don’t accept the invalid object from the
previous block, but we are not validating that new interfaces will satisfy the
base fields like BasicType
.
For example we can create this new valid interface
interface AnimalMammal extends AnimalBase {
type: "MAMMAL";
basicType: "DOG"; // We replaced the basic type and type!
}
It will satisfy the following object:
{
name: 'John',
birthDate: new Date(1995, 1, 1),
type: 'MAMMAL',
basicType: 'DOG',
}
To improve that, we can make sure that some fields in AnimalBase
have
reasonable type. If it gets overridden, it must satisfy those types:
type AnimalBasicType = "BIRD" | "REPTILE";
type AnimalTypeReptile = "TURTLE" | "SNAKE";
type AnimalTypeBird = "CHICKEN" | "EAGLE";
interface AttributesBase {
amountOfLegs: number;
canFly: boolean;
length: number;
}
interface AttributesBird extends AttributesBase {
amountOfLegs: 2;
}
interface AttributesReptile extends AttributesBase {
amountOfLegs: 4 | 0;
canFly: false;
}
type Attributes = AttributesReptile | AttributesBird;
interface AnimalBase {
name: string;
birthDate: Date;
countries: string | string[];
basicType: AnimalBasicType;
type: AnimalType;
attributes: Attributes;
}
interface AnimalBird extends AnimalBase {
countries: string[];
basicType: "BIRD";
type: AnimalTypeBird;
attributes: AttributesBird;
}
interface AnimalReptile extends AnimalBase {
countries: [string];
basicType: "REPTILE";
type: AnimalTypeReptile;
attributes: AttributesReptile;
}
type Animal = AnimalReptile | AnimalBird;
Now the AnimalMammal
will raise an error:
Interface 'AnimalMammal' incorrectly extends interface 'AnimalBase'.
Types of property 'basicType' are incompatible.
Type '"DOG"' is not assignable to type 'AnimalBasicType'.
Good! Now it is more future-proof, as we ensure that all types will a more consistent fields types.
The thing is that it is a bit repetitive, and maybe we can reduce some lines. It is also not ensuring the new interfaces will override the required fields to a precise type. For that, we can use generic types:
type AnimalBasicType = "BIRD" | "REPTILE";
type AnimalTypeReptile = "TURTLE" | "SNAKE";
type AnimalTypeBird = "CHICKEN" | "EAGLE";
interface AttributesBase {
amountOfLegs: number;
canFly: boolean;
length: number;
}
interface AttributesBird extends AttributesBase {
amountOfLegs: 2;
}
interface AttributesReptile extends AttributesBase {
amountOfLegs: 4 | 0;
canFly: false;
}
interface AnimalBase<
TAnimalBasicType extends AnimalBasicType,
TAnimalType extends AnimalTypeReptile | AnimalTypeBird,
TAttributes extends AttributesBird | AttributesReptile,
> {
name: string;
birthDate: Date;
countries: string[];
basicType: TAnimalBasicType;
type: TAnimalType;
attributes: TAttributes;
}
interface AnimalBird
extends AnimalBase<"BIRD", AnimalTypeBird, AttributesBird> {}
interface AnimalReptile
extends AnimalBase<"REPTILE", AnimalTypeReptile, AttributesReptile> {
countries: [string];
}
type Animal = AnimalReptile | AnimalBird;
That is a modular, precise, and safe type system. Of course, it has drawbacks because it requires some TypeScript knowledge, and it can be improved even more. But it is up to you to find the correct balance for your use-cases.
Conclusion
I demonstrated a few ways of solving the same problem, and there are many more. TypeScript, unlike other languages, is very flexible and has many powerful features that can help you make a type system that fits your needs.