Typescript for Java Developers: Index Types

A fairly recent addition to Typescript is index types and the keyof operator. For a Java developer this is an interesting thing to learn about, as Java doesn’t have this feature, specifically due to type system inflexibility.

Academically speaking, an index type is a small facet of dependent type systems (where one type in use is dependent upon the value of another input). This is also, in effect, a way to get many of the benefits of a heterogenous map. The abilities of index types and the keyof operator are rooted in two features:

  1. The ability to declare a sealed enum of all possible property names on a type
  2. The ability to look up the type of any property using an indexed access operator lookup based on values at runtime.

Without getting too much deeper into the academia of the feature, let’s consider this Java type:

1public class Person {
2  int age;
3  String name;
4  // getters, setters, etc.
5}

It would be nice in theory to be able to create a generic builder API that works like this:

1public class PersonBuilder {
2  private Map<String, Object> fieldValues = new HashMap<>();
3  public PersonBuilder set(String field, Object value) {
4    fieldValues.put(field, value);
5    return this;
6  }
7
8  public Person build() { /* build from the map of values */ }
9}

Ideally the usage would be clean:

1Person person = personBuilder
2  .set("age", 15)
3  .set("name", "Bobby Tables")
4  .build();

However, this has a huge glaring type issue (and this is why we don’t do this with builders in Java). Notably:

1personBuilder.set("age", "oops"); // age has to be a number!
2personBuilder.set("gender", "Male"); // gender is a not a field of person!

This, of course, can be enforced with reflection and/or generated code, but that is a mess and not even remotely ideal (magic functionality, performance implications, etc etc etc).

This magic type dependency is what index types do: they give us a dependent type on which we can perform contextual lookups the compiler will respect. Here is a concrete example in TypeScript:

1interface Person {
2  age: number
3  name: string
4}
5
6type PersonOption = keyof Person; // == 'age' | 'number'
7

As you can see, keyof is nothing more than compiler supported sugar for defining a fixed type of possible string values. However, since it automatically reflects changes to the type, it is generally superior to defining this enum type manually.

This limits variable inputs to real properties on Person. Already this allows us to write a function that limits the string inputs to fields on the source type:

1function someFunction(input: PersonOption) { /* ... */ }
2
3someFunction('age'); // valid
4someFunction('name'); // valid
5someFunction('gender'); // compiler error! not a field of Person
6

This already is a feature that Java doesn’t have: the ability to restrict a dynamic string to the properties on a compiled type. However, where the real power of keyof arrives is the ability to do a dependent type lookup on the Person interface using the input value. This power comes from the other feature: the indexed access operator.

As a contrived example to understand indexed access operators, I could write a function like this:

1function setAge(ageValue: Person['age']) {
2  /* ... */
3}
4

What the ageValue: Person['age'] type declaration says is this: “The type of ageValue should be whatever the type of the ‘age’ property of Person is”. Of course, this is silly, we already know the type of age, so why write the function this way and not: setAge(ageValue: number).

The answer to that is that keyof types can be used dependently within the same function so that one parameter is enforced by the other. Specifically:

1function set<T extends PersonOption>(field: T, value: Person[T]) {
2  // ...
3}
4

This ensures that, based on the name provided for field, the value parameter must match the given type of that field on the underlying object. Now we get two benefits in one: the field must be a valid field of the type, and the value must match the type of that field:

1set('age', 15); // allowed
2set('age', 'test'); // compiler error in typescript: age is a number
3set('gender', 'male'); // compiler error in typescript: gender is not a field of Person
4

We now get enforcement of both the allowed parameter types as well as the types of the provided value of that parameter name.

Finally, it should be noted that keyof can be used inline to a function, and in fact that is probably the more common usage pattern:

1function set<T,K extends keyof T>(name: K, value: T[K]) {
2  // ...
3}
4

This function still works as expected, but can in fact work for any type T, based on the parameterization in the code.

comments powered by Disqus