DEV Community

Cover image for The Tinkering Hobbit (excerpt from The hidden language within the language)
Pato Z
Pato Z

Posted on

 

The Tinkering Hobbit (excerpt from The hidden language within the language)

The night falls over The Shire and gray smoke billows from the chimneys as every house gets ready for a hot supper and some stories before bed.

Every house except one.

In the distance, off the beaten path, in a strange house (strange by hobbit standards) something else is going on. You could tell it was strange by the plumes of green and purple smoke coming out of its roof, or by the clinks and clanks coming from inside.

This is not an ordinary hobbit house, in here lives...

The Tinkering Hobbit

Tinky the Tinkering Hobbit is the one responsible for all the cool hobbit contraptions you see out there. Adventuring hobbits have no time to learn how stuff actually works.

Back in high-school, some hobbit bullies called Tinky "non-canon" because in the 9 hours of chasing a ring all over the land (extended director's cut), plus some other 9 hours of stealing treasure from a mean dragon, there was absolutely no mention of Tinky.

The Tinkering Hobbit never paid any attention to those comments. Understanding the inner workings of things, that was the ultimate goal.

But Tinky knew understanding was not all, sharing the knowledge was equally important, and so, a community was born, almost like a "fellowship", although they preferred to be called "The Tinkerhood".

For months Tinky has been researching, tinkering, trying, failing, and trying again.

One day after knocking down half a sink with a headbutt and having flux capacitor flash forwards, Tinky saw something in the edge of the metaphorical vision. Something so clear and yet so elusive, something that was there all along, hidden in plain sight, something that could change everything.

But Tinky knew well that the key to truly understanding stuff was sharing the knowledge so a book was born. A book we partially reproduce here for all of you honorary members of The Tinkerhood. A book called...

The hidden language within the language

(excerpt from Tinky's book)

Hi there, my name is Tinky, the Tinkering Hobbit. You know me, I've been tinkering since forever. Today I'd like to tell you about my recent explorations into the world of types, more specifically of Typescript.

Some of the rabbit holes in this field are deeper than the deepest pits of Moria, so we'll tread carefully and take it slowly.

Let's start with...

Unions, really?

Typescript has something called "Union Types". You write union types using a | like this:

type Monster = Orc | GiantSpider;
Enter fullscreen mode Exit fullscreen mode

You read this as "a Monster is either an Orc or a GiantSpider".

I'm sure there's an occult field of magic where calling this a "union" makes sense, but if you are a simple tinkerer like myself, one perhaps familiar with set-union or the bitwise operator |, then this will be at least a little confusing.

If you inspect that Monster type, you'll see it has readily available only the parts that both an Orc AND a GiantSpider have in common. Now if you think about sets, a set-union has the stuff that EITHER or BOTH of its components have.

I guess what I'm trying to say is: don't pay much attention to the name. These are called "union types" (even though they work like intersections or something) but it doesn't matter they could've been called something else.

Do know that in this book when we refer to "union types" we are talking about these things and not what you might intuitively think of as "unions".

An objective view

Have you ever been into object-oriented programming? In there you used to have classes and such. Some classes were abstract representations of other classes, like:

class Monster { /*... */ }

class Orc extends Monster { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

A Monster is an abstract representation of an Orc. Or an Orc is a more concrete type of Monster.

Now, in OOP, a recurring problem (and cause of great anxiety) is called "downcasting".

Downcasting is the act of taking a generic thing, like a Monster that you know (how?) to be a specific thing, like an Orc and asserting that this thing is an Orc, like this:

function f(monster: Monster) {
  const orc = monster as Orc; // how do you know this, pray tell?
}
Enter fullscreen mode Exit fullscreen mode

Note that the reverse problem, know as "upcasting" or treating something specific as something generic, just works:

const orc = new Orc();
const monster: Monster = orc;
Enter fullscreen mode Exit fullscreen mode

Why are we talking about this, you ask?

Well...

Narrow and wide

A very similar problem occurs with union types. Let's say we have:

type Monster = Orc | GiantSpider;
Enter fullscreen mode Exit fullscreen mode

And we have a monster variable of type Monster that we know (?) it's an Orc and we need to assert that.

When dealing with union types, what we used to call "downcasting" is now called "narrowing" as in "you take a wider, more generic type and you narrow it into a more specific one".

That being said, narrowing and downcasting are pretty much the same and we could use a similar hack as we did with classes:

function f(monster: Monster) {
  const orc = monster as Orc; // how do you know this, pray tell?
Enter fullscreen mode Exit fullscreen mode

But there are a couple of better options. We could use a discriminating property like this:

interface Orc {
  type: 'orc';
}
interface GiantSpider {
  type: 'giant-spider';
}
type Monster = Orc | GiantSpider;
Enter fullscreen mode Exit fullscreen mode

So that we can later use monster.type to check for 'orc' and get type-checked narrowing:

function f(monster: Monster) {
  if (monster.type === 'orc') {
    // in here the type of `monster` is `Orc`
  }
}
Enter fullscreen mode Exit fullscreen mode

En garde!

Another, more advanced, narrowing alternative is to use something called "type guards" (also known as type predicates). These are just functions that take monsters and return a fancy boolean stating that you just checked and yeap, this monster is an Orc.

const isOrc = (m: Monster): m is Orc =>
  // some logic here (is smelly, is clumsy, etc.)

// note the `m is Orc` part which is the "fancy boolean"
Enter fullscreen mode Exit fullscreen mode

Type guards are the answer to those "how did you know that?" questions above.

To be fair, technically all these strategies for type-checked narrowing are called "type guards" (e.g. type predicates, discriminating properties, the typeof operator, etc.), but I grew up calling type predicates "type guards" and old habits die hard.

With all that warm up out of the way, it's time for a crucial choice.

Red berry vs blue berry

I once read of a powerful wizard called Morpheus that once offered his apprentice N00b a choice between two berries: eat the red berry and your eyes will be open, but there will be no turning back..., or take the blue berry and enjoy blissful ignorance.

Dear reader, I'm giving you the same choice right now. Keep reading at your own peril or choose to walk away now oblivious to the power (and responsibility) ahead.

Hidden in plain sight

Let's say that we have a function:

const someFn = (t: number): number =>
  /* do something with `t` */
Enter fullscreen mode Exit fullscreen mode

This function takes a single argument t of type number and returns another number.

Now look at this:

type SomeFn<T> =
  /* do something with `T` */
Enter fullscreen mode Exit fullscreen mode

Now squint your eyes and look at both together. Do you see it? Both look like functions, right?

someFn is a run-time function while SomeFn is a compile-time function!

We could say that SomeFn takes a type argument T and returns some other type value.

In other words the type alias SomeFn acts as a function over types instead of a function over values.

Let's see another example:

type MakeArray<T> = T[];
Enter fullscreen mode Exit fullscreen mode

This MakeArray function takes a type argument T and returns a new type T[].

So we already have variables like T above (capable of holding types) and we have functions (like MakeArray and SomeFn) capable of producing new types from existing types...

Do you see where I'm going with this?

The big reveal

We should be able to write pretty complex programs just by combining the elements above (i.e. variables and functions). These are not regular programs, they are programs that operate on the type-system of our "real" (or should I say "other") programs.

We could craft our type-system programs to provide better domain-specific compilation (think DSLs, encode business rules in the type-system, etc.).

But for us to be able to do really cool stuff in this hidden language we need a couple more elements.

Who types the types?

Did you spot it back in the SomeFn example? Here's again all together:

const someFn = (t: number): number =>
  /* do something with `t` */

type SomeFn<T> =
  /* do something with `T` */
Enter fullscreen mode Exit fullscreen mode

See how we know t is a number but we have no clue what T is?

We went to all the trouble of using Typescript because we believe types are important and now that we are coding at the type-system level we have no types?

Outrageous!

...actually, there is a way for us to specify types in our type-system programs:

type SomeFn<T extends number> =
  /* do something with `T` */
Enter fullscreen mode Exit fullscreen mode

So what you do with : in run-time land you can do with extends in compile-time land.

But what about...

Narrowing^2

What if we have something like:

type F<T extends Monster> =
  // do something if T is an Orc
Enter fullscreen mode Exit fullscreen mode

How can we narrow T from Monster to Orc? Well, the answer is something called conditional types but I like to think of these as good old ternary operators:

type F<T extends Monster> =
  T extends Orc
    ? // T is an Orc (or maybe an Uruk-Hai?)
    : // T is not an Orc
Enter fullscreen mode Exit fullscreen mode

If you don't want to allow one of the branches of your conditional types you could use the handy type never (I shudder to think of a run-time analogy for this):

type OnlyForOrcs<T extends Monster> =
  T extends Orc
    ? // T is an Orc (or maybe an Uruk-Hai?)
    : never;
Enter fullscreen mode Exit fullscreen mode

OK, that's more like it, we have functions, variables and conditional expressions. What else do we need?

Unions as lists

We talked a lot about union types, truth is, for our type-system programs a union can function pretty well as a list. You need to return a bunch of stuff, you could return a union type of that.

Let's see some examples. You're probably familiar with good old Object.keys:

const a = { a: 1, b: true };

const keysOfA = Object.keys(a); // ['a', 'b']
Enter fullscreen mode Exit fullscreen mode

Object.keys takes an object and gives you back an array of its keys (strings, numbers or symbols).

Ready for another squinting eyes moment? Here it goes:

interface A { a: number; b: boolean }

type KeysOfA = keyof A; // 'a' | 'b'
Enter fullscreen mode Exit fullscreen mode

By now you are an expert at this. keyof T is the type-system equivalent to Object.keys(t), but instead of returning an array, it returns a union type.

Let's see the other obvious example, I'll throw it all together this time:

const a = { a: 1, b: true };

const valuesOfA = Object.values(a); // [1, true]

// -----

interface A { a: number; b: boolean }

type ValuesOfA = A[keyof A]; // number | boolean
Enter fullscreen mode Exit fullscreen mode

Clearly there's a pattern here.

Unions and the Occult

One of my favorite run-time operations over arrays is without a doubt .map. Here's a refresher:

const a = [1, 2];

const b = a.map(value => value % 2 === 0) // [false, true]
Enter fullscreen mode Exit fullscreen mode

.map takes a function and an array (via this), it then applies that function to every element of the array and returns a new array with these mapped values.

When dealing with this type of operations, some people with a magical proclivity like to use a spell called "The Functor". We tinkerers are not usually into magicks so we'll just stick to thinking of map as a function that can change every element in an array to something else, preserving order, cardinality, etc.

We said some time ago that union types could double as type-system arrays.

Wouldn't it be great to be able to map over the types wrapped in a union type? Ha, you knew this was coming didn't you?

Type-system map

The type-system version of map (i.e. map over types rather than run-time values) requires a bit more work but it's perfectly doable:

type Monster = Orc | GiantSpider;

type MakePet<T> = T extends unknown
  ? WashCleanAndDress<T>
  : never;

type GoodMonster = MakePet<Monster>;
  //             =
  //               | WashCleanAndDress<Orc>
  //               | WashCleanAndDress<GiantSpider>
Enter fullscreen mode Exit fullscreen mode

Let's go over all of this nonsense step by step!

We start with a Monster type that is a union of Orc and GiantSpider (remember that we think of unions as arrays for now).

Then we create a mapping function that is called MakePet. MakePet will unpack each monster type from the union, call WashCleanAndDress for each type and then neatly pack them back into a union type.

MakePet works like map because it does the unpacking / packing by exploiting something called distributive conditional types.

This essentially means that whenever you do T extends U ? SomeFn<U> : never this will not only pattern match T and U but also, when T is a union type, "distribute" SomeFn over every type in T that extends U.

If you combine this knowledge that extends distributes with the fact that every type extends unknown you get the raw mapping pattern:

type ApplySomeMapping<T> = T extends unknown
  ? SomeMapping<T>
  : never; // won't happen, every T extends unknown
Enter fullscreen mode Exit fullscreen mode

If you've been paying attention you might have noticed that the type-system map requires us to bind two type functions:

type ApplySomeMapping<T> = T extends unknown
  ? SomeMapping<T>
  : never; // won't happen, every T extends unknown

type MappingResult = ApplySomeMapping<A | B>;
Enter fullscreen mode Exit fullscreen mode

This is necessary because if we tried to inline A | B in ApplySomeMapping we'd break distribution. Here's why, step by step:

// step 0 (this one works)
type X = A | B;

type ApplySomeMapping<T> = T extends unknown
  ? SomeMapping<T>
  : never;

type Result = ApplySomeMapping<X>;

// -----

// step 1 (inline X in ApplySomeMapping, it's broken)
type X = A | B;

type Result = X extends unknown
  ? SomeMapping<X>
  : never;

// -----

// step 2 (inline X in Result to make it obvious it's broken)
type Result = (A | B) extends unknown
  ? SomeMapping<A | B>
  : never;
Enter fullscreen mode Exit fullscreen mode

If we tried to merge Result with ApplySomeMapping we would be pattern matching (and distributing) X, but then we'd end up applying SomeMapping over the whole X.

If this was run-time, it'd be the same as doing:

x.map(_ => someMapping(x));
Enter fullscreen mode Exit fullscreen mode

When what you wanted probably was:

x.map(value => someMapping(value));
Enter fullscreen mode Exit fullscreen mode

Wow, that was harder on the brain than a honey mead hangover.

Maybe it's time for a recap...

Cheatsheet of Typescript type-system programming

Let's summarize all this craziness.

Functions

// run-time
const someFn = (t: number) => /* use `t` */

// compile-time
type SomeFn<T extends number> = /* use `T` */
Enter fullscreen mode Exit fullscreen mode

Narrowing

type Monster = Orc | GiantSpider;

// run-time
if (monster.type === 'orc') {
  // in here the type of `monster` is `Orc`
}
// or
const isOrc = (m: Monster): m is Orc =>
  // some logic here (is smelly, is clumsy, etc.)

// compile-time
type F<T extends Monster> =
  T extends Orc
    ? // T is an Orc (or maybe an Uruk-Hai?)
    : // T is not an Orc
// or
type OnlyForOrcs<T extends Monster> =
  T extends Orc
    ? // T is an Orc (or maybe an Uruk-Hai?)
    : never;
Enter fullscreen mode Exit fullscreen mode

Unions as lists

// run-time
const a = { a: 1, b: true };
const keysOfA = Object.keys(a); // ['a', 'b']
const valuesOfA = Object.values(a); // [1, true]

// compile-time
interface A { a: number; b: boolean }
type KeysOfA = keyof A; // 'a' | 'b'
type ValuesOfA = A[keyof A]; // number | boolean
Enter fullscreen mode Exit fullscreen mode

Mapping over unions

type Monster = Orc | GiantSpider;

// run-time
const pets = monsters.map(washCleanAndDress);

// compile-time
type MakePet<T> = T extends unknown
  ? WashCleanAndDress<T>
  : never;
type Pets = MakePet<Monster>;
//        =
//          | WashCleanAndDress<Orc>
//          | WashCleanAndDress<GiantSpider>
Enter fullscreen mode Exit fullscreen mode

Only returns void

Cool so now that we have that cheetsheet, it's time for an example and then a challenge.

Let's say I have an object type, and I really don't care about the keys but I do want every value to be a function that returns void.

You might be tempted to say:

type OnlyReturnsVoid = Record<string, () => void>;
Enter fullscreen mode Exit fullscreen mode

But what I meant was this:

interface OnlyReturnsVoid {
  log: (text: string) => void;
  queue: (message: Message) => void;
  start: () => void;
}
Enter fullscreen mode Exit fullscreen mode

See how functions in OnlyReturnsVoid take different numbers and types of arguments? We want to preserve that (i.e. if I call onlyReturnsVoid.log(1) it should complain that 1 is not a string).

How can we assert that future changes to our OnlyReturnsVoid don't break the rule? In other words how can we make this change break the build?

interface OnlyReturnsVoid {
  getLogLevel: () => string; // compilation error!
  log: (text: string) => void;
  queue: (message: Message) => void;
  start: () => void;
}
Enter fullscreen mode Exit fullscreen mode

We can approach this problem by writing a type function to validate that a function returns void:

type IsVoidFn<T> = T extends (...args: any[]) => infer U
  ? U extends void
  ? T // This is OK (`T` is a fn returning void)
  : never  // `T` is a function that returns stuff
  : never // `T` is not a function
Enter fullscreen mode Exit fullscreen mode

Note that here we are using infer. You can read more about it here.

Now that we have that validation function we'll use another advanced type-system construct called mapped types to map over the keys of OnlyReturnsVoid and apply IsVoidFn to every value:

type Validator = {
  [K in keyof OnlyReturnsVoid]: IsVoidFn<OnlyReturnsVoid[K]>
};
//             = {
//                 getLogLevel: never;
//                 log: (text: string) => void;
//                 queue: (message: Message) => void;
//                 start: () => void;
//             };
Enter fullscreen mode Exit fullscreen mode

See how our Validator type now has never for the invalid function? The last trick is to create a new interface that extends from both the OnlyReturnsVoid and our Validator:

interface OnlyReturnsVoidGuard extends
  OnlyReturnsVoid,
  Validator {}
Enter fullscreen mode Exit fullscreen mode

You can play around with this here.

Now the challenge: can you change this example so that OnlyReturnsVoid allows embedded objects that also return void, like this one?

interface OnlyReturnsVoid {
  getLogLevel: () => string; // compilation error!
  log: (text: string) => void;
  queue: (message: Message) => void;
  service: {
    getCount: () => number; // compilation error!
    start: () => void;
    stop: () => void;
  }
}
Enter fullscreen mode Exit fullscreen mode

You can do it here. Can you change the validator so that it checks nested objects of arbitrary depth?

(the excerpt of the book ends here)

Final thoughts (and a shameless cliffhanger)

I don't know about you, dear reader, but I really love Tinky's explorations, to the point that I almost consider myself part of "The Tinkerhood".

About Tinky's current adventures little is known but it is said that a powerful wizard atop an evil tower once pointed an eye-shaped telescope in Tinky's direction and got a glimpse of a new book in the making.

The working title for that book appears to be: Tunneling into the unknown: portaling between run-time and compile-time programs in Typescript...

I can hardly wait!

Top comments (0)