One of the most important aspects of a programming language is the type system. The type system will inform the design decisions and the overall structure of the code. If used effectively, it can provide a number of benefits, such as:
- separation of concerns,
- modularity,
- extensibility,
- improved correctness
This blog post is going to focus on the key differences between Typescript and Java type systems, and how they shape our decisions at Kahoot. Let’s get started!
Structural Typing
The first key difference is that the type system in Typescript supports structural typing, while Java does not. This means that types in typescript are compared by their fields, while in Java they are compared by the identity. This has the following implications:
- Typescript embraces duck typing.
- In Typescript, you are encouraged to use union types, while in Java you are encouraged to use abstract classes with inheritance or casting.
Let’s explore both of these points in further detail.
Duck Typing
Duck typing in computer programming is an application of the duck test — “If it walks like a duck and it quacks like a duck, then it must be a duck”
Let’s consider the following example in Typescript:
interface Cat { name: string; color: string; } interface City { name: string; country: string; } interface Named { name: string; } const cat: Cat = { name: "Mr. Cat", color: "orange" } const city: City = { name: "New York", country: "USA" } const logName = (named: Named) => console.log(named.name); logName(cat); // "Mr. Cat" logName(city); // "New York"
Woah, wait a minute! Cat and City don’t inherit from Named, what is this magic? – This is duck typing at work. The compiler doesn’t care about the inheritance structure, as long as the fields which are needed are present. ? In Java, Cat and City classes would have to explicitly extend the Named interface for this to work.
This has two subtle implications:
- In Typescript, you can introduce micro-types specific to a certain function or operation, like the Named interface above. This makes your helper methods very atomic and uncoupled from any specific type hierarchy.
- In Java, you have rely on the Adapter pattern to achieve the same kind of modularity.
This becomes more apparent when you work with third-party code. For example, if Cat was part of a third-party library, then in Java things get complicated, because you cannot simply change its type signature. Moreover, you cannot pass it to a universal function such as printName, even if it exposes a public getName method.
To achieve the same kind of modularity in Java, one would have to rely on the Adapter pattern:
import com.catlib.Cat; // Third-party class public class City implements Named { private String name; private String country; public String getName() { return name; } } public class NamedCatAdapter implements Named { private Cat cat; public NamedCatAdapter(Cat cat) { this.cat = cat; } public String getName() { return this.cat.getName(); } }
Union Types and Inference
A union type in Typescript is defined like this:
type MyUnionType = string | number; let myVariable: MyUnionType; myVariable = 1 // This is ok myVariable = "Hello World"; // This is also ok myVariable = true; // This is NOT ok
In Typescript, if you have a complex object type, the compiler is always aware of the types of the inner fields. This means that in if-statements or switch-statements we get type inference. Type inference is, simply put, automatic casting of objects to correct types based on some condition.
The way this is most commonly used in Typescript is with a discriminator field, which is usually an enum. Let’s take a look at a specific example:
enum Species { Cat, Bird } interface Cat { species: Species.Cat; // discriminator field furColor: string; } interface Bird { species: Species.Bird; // discriminator field wingSpan: number; } const describeAnimal = (animal: Animal): string => { switch (animal.species) { case Species.Cat: // Type inference: animal is automatically cast to Cat return `${animal.furColor} cat`; case Species.Bird: // Type inference: animal is automatically cast to Bird return `bird with a ${animal.wingSpan} wing span`; } }
Subtle Points
The first subtle point in this example is that in the definition for Cat and Bird interfaces, we used Species.Cat and Species.Bird as types for the species discriminator field. This is possible, because in Typescript, every constant is also a type. It represents a field which can only have one value.
To effectively use union types, the recommendation is to have a discriminator field with a unique value for each type that goes into the union. This makes it very easy to write the switch-statements.
The second subtle point has to do with runtime correctness of code. Let’s imagine that in Typescript you are getting objects from an external API, and you assume they are of Animal type. Consider the following code:
const animal: Animal = await externalApi.getAnimalById(123); if (animal.species === Species.Cat) { console.log(animal.furColor.toLowerCase()); // We get no compile time errors, but this // can result in a runtime error! }
The reason for this is that Typescript does not perform any runtime checks on objects. The only runtime check that happens in our code is the one we write in the if statement. Because we are only checking for the species field, there is no guarantee at runtime that the API will also return the furColor field.
For this reason, at Kahoot, mission critical objects coming from the API or the web socket layer are checked against a schema at runtime, to ensure that what we received is what we actually need.
Exhaustiveness Checks
The union type → switch pattern in Typescript is particularly powerful, because the compiler can also tell if you are handling all possible values of a discriminator field such as species. This is called exhaustiveness checking, and it helps you avoid errors like forgetting to handle a specific case. When the codebase gets larger, it is very important to have these kinds of errors caught at compile time.
Let’s say we introduce a third animal species: Dragon! Unless we update our describeAnimal function, the compiler will throw an error:
type Animal = Cat | Bird | Dragon; const describeAnimal = (animal: Animal): string => { switch (animal.species) { case Species.Cat: return `${animal.furColor} cat`; case Species.Bird: return `bird with a ${animal.wingSpan} wing span`; // The compiler will complain, unless we include the following: case Species.Dragon: return `dragon with ${animal.fireTemperature} fire temp.`; } }
Unions in Java
The equivalent of a union type in Java is a class hierarchy, where you have an abstract class in the root, which defines the interface, and you have a number of subclasses which all implement this interface. That is how, in Java, you can ensure that all objects, which must be treated uniformly, implement all the necessary methods.
However, this can lead to a very wide public interface of the root class, and it might happen that for certain subclasses, conceptually some methods are not applicable. Further, you can only inherit from one class, so if you start developing orthogonal features which operate on the same data, the problem grows geometrically.
Back to the the Cat and Bird example, in Java, we would have 2 approaches:
- Have the Animal class expose both getFurColor and getWingSpan methods, which clearly don’t make sense for all animal species.
- Use the instanceof operator.
The first approach gives us complete type safety and exhaustiveness checking, because it forces us to implement all methods in all subclasses. In other words, it gives you improved correctness, but takes away modularity, separation of concerns and ultimately extensibility.
In the second approach, we do not have to implement all methods, which gives us modularity and extensibility, but we lose type safety and exhaustiveness checking. Here is an example of the second approach. Notice how in Java, we still have to explicitly cast:
public String describeAnimal(Animal animal) { if (animal instanceof Cat) { return ((Cat) animal).getFurColor() + " cat"; } else if (animal instanceof Bird) { return "bird with a " + ((Bird) animal).getWingSpan() + " wing span"; } }
Method Overloading
The final area we are going to explore is method overloading. While both languages support it, this is where Java shines over Typescript. Method overloading allows us to define more than one method that share the same name, but have different arguments.
Let’s look at an example in Java to illustrate this:
public class AnimalHelper { public static String describe(Cat cat) { return "This is a cat"; } public static String describe(Bird bird) { return "This is a bird"; } } // Later in code AnimalHelper.describe(new Cat()); // => "This is a cat" AnimalHelper.describe(new Bird()); // => "This is a bird"
This pattern is used quite often in Java, because Java is able to infer which method to dispatch based on the types of arguments passed to it at compile time. This keeps the code concise and extensible.
On the other hand, Typescript is different from Java in the following ways:
- In typescript you cannot define a different method body for a different set of arguments.
- Typescript is not able to dynamically determine which function to call.
Let’s take a look at the equivalent implementation in Typescript:
function describeAnimal(animal: Cat): string; function describeAnimal(animal: Bird): string { if ("wingSpan" in animal) { return "This is a bird"; } else { return "This is a cat"; } }
Yes, we can define more than one signature for the same function, but we cannot define different bodies. Instead, we have to inspect the passed arguments at runtime, to determine which overload of the function was invoked. Obviously, this is much more error prone and makes the code more complex.
There are ways around this. For example, we can use different function names for different sets of arguments, or we can use a type specific to a function, like the Named interface above.
Further, this emphasises one very important difference between Typescript and Java. In Typescript, the types and method definitions are only present at compile time, and do not affect runtime behaviour. This is well explained in Typescript Design Goals. The two most important points in the context of this article are:
- Impose no runtime overhead on emitted programs.
- [Do not] add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata.
Takeaways
- Typescript embraces duck typing, while in Java there are compromises to be made between wide public interfaces or explicit casting.
- Typescript supports better compile time correctness checks, specifically around exhaustiveness checking.
- Typescript does not affect the runtime code, thus:
- No runtime correctness is guaranteed, as shown in the API example.
- Approaches which rely on type information at runtime such as reflection are discouraged.
- Java has first-class method overloading, while Typescript does not provide good support for this.
Thank you for reading! We hope this article helped you get a quick overview of the differences between Java and Typescript type systems. As always, in software there is no single answer. Every decision is a compromise, and to make good decisions, it is important to be curious and open to new approaches.