loading...

Mostly typeless TypeScript

webpapaya profile image webpapaya ・8 min read

Coming from a JavaScript and Ruby background I never thought about static typing in my code. Besides some algorithm and data structure lectures at university I had almost no touchpoints with statically typed languages. When coding in Ruby or JS I normally rely on TDD for business logic and some integration or "happy path" tests to verify the interaction between the components. Testing the correct wiring between function/method call on the integration level is almost impossible as the amount of tests grow exponentially with every additional code path (see Integrated tests are a scam) by @jbrains. In 2017 there had been quite some fuzz about Making Impossible States Impossible in elm and after seeing Scott Wlaschin talk about Domain Modeling Made Functional I began to realise that the missing piece to my integration test problem could be type checking.

In spring 2019 I was working at a company which decided to switch to TypeScript (abbr. TS) as their main language of choice for an upcoming project. Working on this project fixed the issue of testing interactions between components, but took a big toll on source-code readability. By accident I came across a library called validation.ts which can derive a TS type from validation functions. Seeing a type being derived completely from a JS data structure made me think that it might be possible to get all benefits of a strong type system without writing any types.

This post will cover the topic of type inference and how application code could be written without type annotations. The first half of this blog post will explain some of the main building blocks of TSs type system and in the second half will try to use those in a way to remove type annotations from the application code.

Type inference

In recent years functional programming (abbr. FP) gained traction and many object oriented languages adapted ideas from this paradigm. One of FPs features is type inference, which describes an automatic deduction of a type from an expression. Depending on the language the extent of type inference can vary from a very basic one where variables don't need to specify the type, to a very sophisticated one where type annotations are mostly used as a form of documentation. Given the following example:

let mutableValue = 10 // => type number
const constantValue = 10 // => type 10

The variable with the name mutableValue is inferred to a number. This means that it can't be assigned to anything else than the number type. The variable constantValue is automatically inferred to the constant 10, as the const keyword prevents a variable from being reassigned.

Generics

A generic is a way to reuse a component with a variety of types rather than a single type. A generic could be seen as a type level function which can be customised with other types. In TS generics are always written between pointy brackets (eg. Array<T>). T is the generic which can be replaced by any valid type (eg. Array<number>, Array<string>). This post won't go into details about generics as the TypeScript Handbook provides an in-depth overview about this topic. TypeScripts type system can also infer some generics like arrays:

const numberArray = [0,1,2,3] // => Array<number>
const stringArray = ['A','B','C','D'] // => Array<string>
const booleanArray = [true,false] // => Array<boolean>

There are situations where array items belong to more than one type (eg.: [1, 'A', true]). TS tries to find the best possible data type and in this case it automatically infers the value to the type Array<number | string | boolean>. The | character is called union or choice, which means that the values in the array could either be a string, a number or a boolean.

const mixedArray = [1, 'A', true] // Array<number | string | boolean>

Const assertion

JavaScript the language itself does not have immutable data types. An immutable datatype is an object whose state cannot be changed after it was created Source. In JS a variable defined as const can still be altered after its creation. That's because the reference to the value is defined as const but the object itself could still change. For arrays this means that items inside the array can be mutated, as well as additions and removals of individual elements.

const numberArray = [0,1,2,3] // => type Array<number>
numberArray[0] = 10;

In the example above the type is inferred to Array<number>. There are cases where this type is considered too wide, as the array won't be mutated and it always contains a well known list of elements (eg. 0,1,2,3). Starting with version 3.4. TS introduced a const assertion which solves the problem of type widening. This converts an object to be readonly and helps TS to better infer its type:

const mutableArray = [0,1,2,'three'] // => type Array<number | string>
mutableArray[2] // => type number

const constArray = [0,1,2,'three'] as const // => type readonly [0,1,2,"three"]
constArray[2] // => literal 2
constArray[3] // => literal "three"

constArray[4] = 4
// ^^^^^^^^^^
// ERROR: Index signature in type 'readonly [0, 1, 2, "three"]' only permits reading.

Adding const assertions makes it possible to get better type information and narrow the type from Array<number | string> to readonly [0,1,2,"three"].

Applying the theory

In order to make the content easier to approach, imagine building a simplified E-Commerce application. The application has 3 different products which can be listed and added to a shopping cart. The functionality of the shopping cart is critical to the business so we need to make sure that nothing besides the known products can be added to the shopping cart. After seeing the requirements we'll start modeling the domain with TS types:

type Product = 'Chair' | 'Table' | 'Lamp'
type ShoppingCart = Array<Product> // Array<'Chair' | 'Table' | 'Lamp'>

Displaying products

The Product is defined as a union of either Chair, Table or Lamp. The ShoppingCart is defined as an array of the Product type, which makes it possible to buy a product multiple times. Having a model definition we can proceed with the implementation of the products list:

type Product = 'Chair' | 'Table' | 'Lamp'

const products: Array<Product> = ['Chair', 'Table', 'Lamp']
const displayProducts = () => { /* irrelevant */}

Looking at this code already reveals one major flaw. Adding a fourth product to the application would require a change in two places. The Product type would need to adapt and in order to display the additional product on the product overview page it is required to alter the products as well. Keeping two constants in sync is an almost impossible challenge and as a result the products array will get out of sync eventually. As we've seen earlier in this post TS can derive types from expressions, so it might be possible to derive the Product type directly from the products array.

const products = ['Chair', 'Table', 'Lamp']
type Product = typeof products[number] // string
//                                        ^^^^^^^
// This type does not match our requirements, as the
// => type string is to loose.

typeof products[number] returns a list of all possible types in the array. Deriving the type of the shopping cart from the products array doesn't yield the expected results, as every possible string becomes a possible product. Earlier in this article TS const assertion was mentioned which would prevent this type widening.

const products = ['Chair', 'Table', 'Lamp'] as const // => type readonly ['Chair', 'Table', 'Lamp']
type Product = typeof products[number] // => 'Chair' | 'Table' | 'Lamp'

This yields the expected result and the implementation can't get out of sync with the types as both are fed from the same data.

Adding to the shopping cart

With the domain we modeled it is not possible to add invalid products to the application. A simplified version of the shopping cart could look like the following:

const products = ['Chair', 'Table', 'Lamp'] as const
type Product = typeof products[number]
type ShoppingCart = Array<Product>

const shoppingCart: ShoppingCart = []
shoppingCart.push('Chair')
shoppingCart.push('Table')
shoppingCart.push('Unknown product')
//                ^^^^^^^^^^^^^^^^^
// ERROR: Argument of type '"Unknown product"' is not assignable to
// parameter of type '"Chair" | "Table" | "Lamp"'.

All the business requirements are met as invalid products can't be added to the shopping cart. The code itself is fully typed but it comes at the cost of readability. Removing the types and converting the app back to JS makes the code easier to read but this also removes all the benefits we gained via TS.

const products = ['Chair', 'Table', 'Lamp']

const shoppingCart = []
shoppingCart.push('Chair')
shoppingCart.push('Table')
shoppingCart.push('Unknown product')
//                ^^^^^^^^^^^^^^^^^
// In JS this error can't be tracked...

What if it would be possible to still maintain type safety and remove almost all TS specific code?

const products = ['Chair', 'Table', 'Lamp'] as const
const shoppingCart = emptyArrayOf(products)

shoppingCart.push('Chair')
shoppingCart.push('Unknown product')
//                ^^^^^^^^^^^^^^^^^
// Argument of type '"Unknown product"' is not assignable to
// parameter of type '"Chair" | "Table" | "Lamp"'.

Besides the const assertion in line 1 it would be impossible to tell wether this is a TS or JS application. So in the following sections we'll have a look on how to convert the fully typed TS example to an almost TS free version. In order to get to the TS free version I tend to differ between business logic related code (without TS) and utility code which contains TS. In the example above the emptyArrayOf function would be considered as a utility function.

Before starting with the implementation of the emptyArrayOf function we need to take a step back and look at generics again. Similar to regular JS functions, TS generics make it possible to reuse certain type logic with different type arguments. Looking at the following function:

const emptyArrayOf = <TypeOfArrayItem>(): Array<TypeOfArrayItem> => {
  return []
}

const stringArray = emptyArrayOf<string>() // Array<string>
const numberArray = emptyArrayOf<number>() // Array<number>
const shoppingCart = emptyArrayOf<'Chair' | 'Table' | 'Lamp'>() // Array<'Chair' | 'Table' | 'Lamp'>

The emptyArrayOf function has a type signature of () -> Array<T>. This means that the function returns an array whose items are of type T.

const emptyArrayOf = <TypeOfArrayItem>(): Array<TypeOfArrayItem> => {
  //                 ^^^^^^^^^^^^^^^^^
  // Define a generic type argument called `TypeOfArrayItem`.
  // The generic type could be seen "type parameter/variable"
  // for later use. Any valid TS type could be used.
  return []
}
// ...
const emptyArrayOf = <TypeOfArrayItem>(): Array<TypeOfArrayItem> => {
  //                                      ^^^^^^^^^^^^^^^^^^^^^^
  // Use the generic type variable `TypeOfArrayItem` to tell TS
  // what the function is returning.
  return []
}

const shoppingCart = emptyArrayOf<'Chair' | 'Table' | 'Lamp'>() // Array<'Chair' | 'Table' | 'Lamp'>
//                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Pass a "type parameter" to the emptyArrayOf function. Due to signature
// of `emptyArrayOf` it returns an empty array of the type Array<Product>.
// This means that no unknown product can be pushed to this array.

Sadly this is not exactly what we wanted to archive, as there is still some TS specific code present. What if the original products array
would be passed in as an argument to the function and TS automatically derives the return type from this.

const emptyArrayOf = <TypeOfArrayItem>(possibleValues: TypeOfArrayItem[]): TypeOfArrayItem[] => {
  //                                                   ^^^^^^^^^^^^^^^^^
  // Introduce a new argument in order to derive the resulting type from it.
  return []
}

const products = ['Chair', 'Table', 'Lamp'] // Array<string>
const shoppingCart = emptyArrayOf(products) // Array<string>
//                               ^^^^^^^^^^    ^^^^^^^^^^^^^
// Pass the products of the shop to the emptyArrayOf function.
// The return type is automatically derived.

As seen above the emptyArrayOf function automatically derives it's type from the given array. Instead of returning an array of valid products the function now returns an Array of strings. Adding a const assertion to the products array should fix this issue.

const emptyArrayOf = <TypeOfArrayItem>(possibleValues: Readonly<TypeOfArrayItem[]>): TypeOfArrayItem[] => {
  //                                                   ^^^^^^^^
  // As const assertions convert an object to be readonly we need to adapt the incoming
  // type to be readonly.
  return []
}

const products = ['Chair', 'Table', 'Lamp'] as const
//                                          ^^^^^^^^^
// Adding the const assertion to the products makes it readonly and allows
// typescript to better infer its type.

const shoppingCart = emptyArrayOf(products) // Array<'Chair' | 'Table' | 'Lamp'>
//    ^^^^^^^^^^^^
// Finally the shopping cart contains the correct type and no invalid product
// can be added.

Removing all the clutter leaves us with the following implementation:

// Utility Code
const emptyArrayOf = <TypeOfArrayItem>(possibleValues: Readonly<TypeOfArrayItem[]>): TypeOfArrayItem[] => {
  return []
}

// Application Code
const products = ['Chair', 'Table', 'Lamp'] as const
const shoppingCart = emptyArrayOf(products)

shoppingCart.push('Chair')
shoppingCart.push('Unknown product')
//                ^^^^^^^^^^^^^^^^^
// ERROR: Argument of type '"Unknown product"' is not assignable to
// parameter of type '"Chair" | "Table" | "Lamp"'.

Conclusion

This post showed the power of type inference in TS and how to derive types from JS data structures. Deriving types from JS is a powerful tool as it adds the benefits of type safety without sacrificing maintainability of the application code. A better readability of the code is a nice side-effect of minimising TS specific application code.

This is the first post of a series of TS related posts I'm planning to write in the upcoming weeks. If you have questions don't hesitate to hit me a message on Twitter or use the comments.

Discussion

markdown guide