Conrad

Typescript shenanigans

Conrad Hoang
Sat Jul 06 2024 Web development

For the past few months, I've worked with a large typescript mono repository code base. For me, who's only ever used pure JavaScript for web development.

It's a good chance to get my hands dirty with real-world enterprise code. After messing it around for some time, I have to say, I do get why folks are so grumpy about Typescripts. In the post, I'll show you some quirks around Typescript and how I overcame them. The truth is

Typescript type system sucks

Yup, you heard that right. It's the biggest beef I got with Typescript till now. The whole point of Typescript itself is the type inference and type safety. But in reality, it's a false dilemma.

The problem with type

Let's say we have a function that supposedly takes a number as an argument, for example.

But is the a argument always the number type?

Well, if you guess that the code would be halt at the start of the execution and throw an Exception about wrong type, you're wrong. That code working fine. Because we did not use that value in our function body.

But if our foo function uses a method belong to Number class like:

The code will now sometimes throw TypeError: a.toFixed is not a function sometimes will not. It's actually weird, considering both the returned type we specified and the methods we invoked inside as they has no capability to throw an exception.

And yes, you can pull a fast one on the Typescript compiler with this as assertion keyword. It seems whacked to me that Typescript gives us a tool to bypass the whole type system on purpose at our dispose.

That example might seem trivial, but it does prove that the type system is not sound in Typescript. In the real world, we encounter them all the time - type conversion. Specifically, We have to deal with data from all over the place - databases, APIs, you name it. We might know the general shape of the data but you can't be sure the value it can hold. It can be anything from string to number to who knows what.

Seems like we're backed to the old JavaScript ones. We cannot sure on what value passed to functions/methods.

as keyword?

That's where the thing with as gets really frustrating. When you use the as keyword, it should do the proper type casting instead of blindly trusting the type we gave it. The behavior I assumed was that it would shut the whole thing down and throw the error instead of trucking along like nothing's wrong, even if the data is completely messed up.

This can lead to errors down the line if we access that value after doing some computation. This would be a nightmare if that code block is critical. Let me tell you, this kind of thing is hardly caught in the unit tests, because it is not a bug, it's your wrong perspective about the worldview. Let's put it like that.

Validating types

So how do we actually catch these errors? The first thing that pops into your head is probably a try-catch block. But where do we put it?

  • Around the function call

    Seems okay, but every time we call a function, we have to add more 5 lines to the code base? Yuck!

  • Inside the function

    Talk about paranoid. Hard-pass!

  • After doing the type assertion with as

    For this to work, we have to call the actual property that get accessed by the callee to make sure it is indeed a number instance and not others due to the effect of duck typing.

If we want to have more grain control, we also have to define our own Exception types. That can be a real drag and cumbersome task considering the syntax and how many places we have to put them in the function. It would make the function body significantly longer and heavily decrease code readability. Till now, I do not see any one used this approach in their code base.

Assertion functions

Alright, maybe the aforementioned ways of catching that error are a bit somewhat over the top, we can narrow them down in the function body with the usage of assertion functions. That concept came out way back in the typescript 3.7.

It'll go like this:

Yep, this works. Also for ones who might wonder the returned type of that function would be. That will be void, in case of error it would throw Exception

Type Guards

Cool, you can see, we use the typeof keyword in the assert function about. That 's one of the type guards. You should use them if you think the assertion functions are too long. (Eg: Primitives, Array, Public Instance Types, etc.).

FYI, it's also called negative space programming. Besides typeof we also have other guards:

  • instanceof for instance types (have proto chains aka Class)
  • Array.isArray to check if the value is an Array
  • in to check if a particular property is in the value

To sum up, uses the assertion functions when you're dealing with the composite type (Eg: Records, Private Instance Types, except for Array.) and guards for others.

Better ways for handle Composite Types

It seems like a decent way of solving that issue.

But to me, It's just too imperative, and for each custom type we throw at it, we have to write a function in this format and set it up with the same old type validation. Besides, for the case of composite type (Eg: Record), We can easily violate the invariant by setting the properties with the invalid value. Is there any way we can streamline that process?

Well, to control how we set the values inside the record, we can actually use Class or Proxy for that.

Personally, I gravitate towards the class ones, mostly because of the syntax. But hey, either one works. It's your call, go with whatever feels right. In this blog, I'll only demonstrate the way I utilized Class. But the concept applied to Proxy as well. Just a different syntax.

Class

Just like other OOP languages, we can supercharge our Record type and make it an instance type

By using the getter and setter in Class we can internally maintain the invariant and guarantee extra things so that every method should be fine to access the inner properties without worrying much about the types. That also helps to enforce the Soc - Separation of concerns principles which is a good thing.

And to further get more out of these Class, we can actually implement these static methods to help with constructing those and destructing it vice versa without manually passing the positional arguments to it.

Zod integration

Pretty cool huh? We can make it even better by integrating the zod package to that code.

Now that's the thing, we can now just focus on the business logic itself without much worrying about the abnormal data, invariant, etc. Everything inside that class must passed all the guards 👍, no more headache.

By the way, you can see that I introduce 2 methods to create an instance of User. These are promote and parse, it will sure come in handy if we want manually create one from the UserPayload or generally just want to passed the data you got from the fetch function.

With that in place, we avoid passing props without any info. In my opinion. This is a bad practice, making it impossible to know what's inside of that prop by casually looking at it.

Of course, the class must follow the identity law so that we can keep peace of mind that these work correctly without worrying much. For this User class, it would be:

User.parse(user.to()) = User.promote(user.to()) = user [^1]

It takes some time to define all this stuff upfront. Trust me, it's worth it, no cap. It saves me a ton of time in debugging and just zeroing in on the business logic side of the function without worrying much about the type problems/ invariant. Takes me 4 months to actually figure this all out after some trials and errors.

Anyway, in the future, I might feel there are some better ways of doing these things, I'll revisit this and update this article. For now, I work for me and I'm pretty happy with it.

That's it for now folks.

Stay awesome!

[^1]: user is an instance of User class