As I expected earlier this year, TypeScript’s popularity has continued to grow and so has the language itself. On the surface, it may seem like nothing more than a way to define a value as an explicit string as well as the inputs/outputs of a function; however, the types within TypeScript take on almost a language themselves when you dig deep enough.

Being fascinated with TypeScript and the direction it is heading, I was curious to see how far I could take it. It seemed pretty plausable to be able to construct some initial binary-related types and build from there.

The Bit

The first and simplest thing we can do is define a bit. A bit can represent 0/1, high/low on off or any other binary, mutually exclusive state. In TypeScript, we can define a bit as either the literal type 0 or the literal type 1 :

Using generics, we can effectively create types that translate one type to another. With the addition of conditional types in TypeScript 2.8 we can create the first simplest bit function`.

Taking this one step further, we can implement bitwise and , or and or using two generic arguments A and B .

Now we can implement a full adder. A full adder should take a carry bit, and two bits to add and result in an output bit and an overflow bit. Moving forward we will name any input bits as A , B and carry-in bit as C_IN . In order to get our result bit we must perform: C_IN xor (A xor B) . And our carry bit will be calculated with: (C_IN and (A xor B)) or (A and B) . The important thing to notice here is we need to calculate A xor B in both the result bit and the carry out bit. This relationship can be more clearly seen in a diagram of the logic gates required:

Since we have all the relevant logic gates implemented, a simple approach might look like this:

However, given the large amount of expansion that will be going on, we need to do better. See how we’re calculating BitXor<A, B> twice?. We can flatten this down by providing a generic argument that defaults to the result we want. Here, we can call that generic arg AB_XOR . Since this is an actual argument, it is possible to pass a different AB_XOR type to the BitAdder type, but in practice, it will need to extend this value anyways and that value will not be provided. The rationale will become apparent below when we wish to reuse intermediate results.

We can see the completed type below:

Now, if we call BitAdder<0, 1, 0> , we are adding 0 & 1 with a carry bit of 0. This results in [1, 0] ; a result bit of 1 and an overflow of 0. Using our new full-adder for bits, we can chain these full-adders and wrap them into larger adders.

The Nibble

The next logical progression from the bit is the nibble. A nibble is 4 bits; half of a byte (the people who came up with these names were cute, huh). Using our definition for a bit, we can define a nibble as a collection of 4 distinct bits:

To construct a “nibble adder”, we will want to chain together 4 full bit-adders complete with a carry-in and an overflow, accepting 2 nibbles as input: First we want to calculate the least significant digit. This digit has no dependencies outside A, B and the carry-in bits.

To calculate the second bit, we have to pull in the previous result’s carry out as the carry in for the next addition.

Similarly for the next bit, we need to pull in the previous adder’s result as well as the result previous to that in order to feed it into the B2_ADDER .

Which, for the most significant digit, results in us passing all 3 previous adder results in order to get the previous result’s carry digit:

The above types are used to calculate each individual bit within the context of the previous results. In our final NibbleAdder we resolve all of the results in order of least dependent to most dependent using the generic default technique we used for our BitAdder . We are able to pass each calculated adder result to the next adder.

The final line uses the result of each individual adder as the result’s bits. By using this technique, we don’t need to re-resolve the final adder’s carry bit because we have already resolved the adder as a whole as a default generic argument.

The Byte

By comparison, Bytes look much more feasable now that we have implemented our nibble adder. The result of a full byte-adder can be represented by chaining two nibble-adders together.

First we can define our byte as a set of 8 individual bits:

Similar to how we added together individual BitAdder results to build a nibble, we add together individual NibbleAdder results to build a Byte.

We spread out the nibble adder results into a singular list of bits to build our byte.

Thanks to all this we can write:

Here, result should resolve to a type of [[0, 1, 1, 0, 0, 1, 0, 1], 0] , with the first entry being our resulting byte and the second being our overflow carry bit.

Conclusion

While this demonstrates how powerful the TypeScript compiler can be, it is not actually quite useful. The result is not available at run-time in anyway, so there is no good way to take advantage of the “compiled” nature of this as one might expect coming from a language with a notion of “compile-time” such as C++. A feature I really hope to see in TypeScript is variadic generics. It would make this a lot easier on a larger scale; but more importantly, it would allow for proper types when currying and referencing function arguments.

Between its long compile-time and lack of real-world use, I won’t continue this specific experiment further. I have heard from the TypeScript community that TS is technically turing-complete, and although this is not demonstrated within my example, it is able to create a complete logic set. To further explore the potentially turing-complete nature I wish to build a Turing Machine from TS types eventually.

I believe the main takewaway here is that TypeScript can help you as much as you want it to help. Defining all variables and functions as any doesn't do you much good. The closer your program's representation in TypeScript is to JavaScript, the more you can assert its correctness at the cost of compile-time complexity.

Other Notes

TypeScript 2.9

NodeJS LTS 8

Compile times seem to vary widly between instantaneously as well as a close to 2 minutes.

If you want to assert that something extends something else, you can define this simple utility function: