DEV Community

Jesse Warden
Jesse Warden

Posted on • Originally published at jessewarden.com

TypeScript Types Lie & How to Improve Them

The following covers the unique aspects of TypeScript’s gradual typing, and how this can lead to types that are not accurate and can lead to bugs and runtime exceptions. We also cover ways to utilize the gradual feature of TypeScript to improve those types in an iterative fashion.

Why Care

TypeScript is a language that utilizes strict types and compiles to JavaScript. The point is some developers like utilizing types as they feel the compiler finds issues with their code faster, especially as the code gets extremely large with many devs contributing. They also feel writing types is easier, and requires less code and things to maintain than unit tests. Unit tests are still needed, however, utilizing types can negate the need for many unit tests. Finally, the feedback loop of dynamic languages switches from “write a little code, run it, debug where it breaks, continue” to “write some types, have compiler verify it, run the tests, continue”.

Thus the trade offs are as follows.

Pros:

  • less code to maintain
  • less unit tests to write
  • more confidence the code will not have runtime exceptions nor null pointers
  • can ensure impossible situations cannot occur by using types to narrow the situation the developer cares about

Cons:

  • more overhead in code to read; you have to read the types
  • compiler errors can be just as obtuse or red-herrings like stack traces
  • types, like code, can be hard to understand
  • types are not always accurate

That last con is key, and what we’ll be covering in this article. The whole point of investing effort into writing types is that when the program compiles, it works, and there are no surprises.

… but TypeScript is gradually typed, so there is gallons of nuance here.

Overall TypeScript can be a net positive if you/your team is bought into using types, using the speed of the compiler, and use the types to narrow your problem domain, while your compiler & build setup is easy to maintain and use. I’ve noticed many teams vary in how strict they want to be. I’m suggesting you follow the below as a bare minimum for greenfield / brownfield code bases, else I question why you would use TypeScript and instead just use JavaScript. There are various nuances for projects where you’re migrating from JavaScript to TypeScript.

What is “a type lie”?

A type lie is when the types specify what a function inputs / returns, but they are either only correct in some circumstances, or just plain wrong.

Let’s look at a type truth first.

const greet = (name:string):string =>
  `Hello ${name}!`
Enter fullscreen mode Exit fullscreen mode

If we pass in a string, we’re going to get back a string. So this is a mostly truthful set of types. Now let’s see what a type lie is.

Consider the following code:

type Person = { name: string }
const parsePeople = (jsonString:string):Array<Person> => {
  return JSON.parse(jsonString) as Array<Person>
}
Enter fullscreen mode Exit fullscreen mode

If you read it the function the types are implying, it says “Pass in a JSON string, we’ll parse it, and give you back an array of People records”. Note I said the word “implying”, not “specifies”. That’s because the above types we’re using are wrong. They are only covering the happy path.

If you don’t know any better, like TypeScript, or perhaps you’re just learning, this is not Willful Ignorance. Meaning, you intentionally ignore the unhappy path. Once you learn about the unhappy paths that I’ll show you, however, unless you fix it, you are now being Willfully Ignorant.

The happy path would look like this:

const people = parsePeople('[ { "name": "Jesse" } ]')
Enter fullscreen mode Exit fullscreen mode

However, there are at least 2 unhappy paths the types are not covering. What about parsing a cow?

const people = parsePeople('🐄')
Enter fullscreen mode Exit fullscreen mode

That throws a runtime exception. A few problems with this.

No Exception Type

The types don’t indicate the function can throw an error; it says it returns an Array<Person>, but should have been instead Array<Person> | never or Array<Person> | undefined to indicate it can either return an Array of Person(s), or never return because it threw an Error.

Caveat: You can’t put never in Union types like this, never acts like a 0 sum type; it’s like adding 1 to 0; you end up with 1. So Array<Person> | never ends up being Array<Person> as far as TypeScript is concerned. Some developers will use never like that to communicate intent, but it’s wrong because the compiler will allow you to ignore the error scenario.

No Type Narrowing

Consider this code:

const people = parsePeople('["🐄", "🐄", "🐄"]')
console.log(people[0].name)
Enter fullscreen mode Exit fullscreen mode

It prints out undefined instead of a string name. This is because they utilized TypeScript type assertions unsafely instead of doing the necessary type narrowing. The types say “we’ll get an Array of Person objects”, but what actually happened was we got an Array full of string cows. The types lie again; or if we’re not personifying them, then they only cover the happy path.

You can check to see if those objects at runtime are in fact Person types instead of something else using type narrowing, or better yet something like Zod or ArkType.

More Accurate Types

So how do we improve these types? There is no good typing I’ve seen in TypeScript that allows you to indicate a function can throw an error, similiar to Java’s checked exceptions. So we should treat errors as values; values can be typed, and can be returned whereas exceptions stop the function from executing and thus returning. This means throwing Exceptions intentionally creates errors we cannot catch with TypeScript’s compiler, and thus makes our less type safe and thus worse. Instead, we should return errors.

Data or Nothing Because Things Went Awry

We could switch to Array<Person> | undefined where the undefined indicates something went wrong. This may be ok; many times errors that occur aren’t recoverable, and the nuanced details aren’t always helpful in a UI because the user cannot really do anything to fix their problem.

So the first part would be to fix the JSON.parse. If you look in TypeScript, JSON.parse is typed as (value:string) => any but its return value is actually something like unknown | throw Exception. So we’ll write some code to handle the possible exception of JSON.parse failing:

try {
  const result = JSON.parse(jsonString)
  ...
  return result as Array<Person>
} catch(error) {
  // log error
  return undefined
}
Enter fullscreen mode Exit fullscreen mode

Next, we need to ensure the undefined covers the case where JSON.parse was successful, but our JSON wasn’t in the shape we were expecting of an Array of People records. For now, we’ll use something like Zod in un-safe mode which will throw an exception if our data doesn’t match our type. Since we’re in a try/catch block already, this will handle that explosion as well.

const result = JSON.parse(jsonString)
return ZodArrayOfPeople.parse(result)
Enter fullscreen mode Exit fullscreen mode

What Went Wrong?

The issue is the types don’t tell us what actually went wrong when the function fails. If we want to log this, either to the console, or to a 3rd party logging / alerting / observability system, we need some way for the types to have that information.

To solve this, we can create a basic Either/Result type. It’s like Promise, but synchronous. Promise indicates a function can either (note the word “either” there) return successfully with a value, or fail. Our Result be used the same way.

type Result
  = Ok
  | Err
Enter fullscreen mode Exit fullscreen mode

The Ok would look like { type: 'Ok' } and Err would look like { type: 'Err' }. The type is a discriminant, and allows TypeScript to tell the difference between the 2 types.

If we change our function signature to:

const parsePeople = (jsonString:string):Result =>
Enter fullscreen mode Exit fullscreen mode

That’s good, but an Ok return value doesn’t actually give us our data, and an Err indicates an error occurred, but not what error occurred and why. Fixing Err is pretty simple; just make it have a string inside since most errors can be reduced to a string we can read and send to any logging system.

type Err = { type: 'Err', error: string }

For Ok, though… it needs to be anything the developer wants. To allow that, we’ll need Ok to have a type parameter; just like functions have parameters, types can have them too.

type Ok<T> = { type: 'Ok', data: T }
Enter fullscreen mode Exit fullscreen mode

If you’ve ever seen a Promise<string> or a Promise<Array<number>>, then your Ok can look similiar, like Ok<string> means you got an Ok back with a string inside it.

Now we can update our function signature to:

const parsePeople = (jsonString:string):Result<Array<Person>> =>
Enter fullscreen mode Exit fullscreen mode

The body of the function would look something like so:

try {
  const result = JSON.parse(jsonString)
  const data = ZodArrayPerson.parse(result)
  return { type: 'Ok', data }
} catch(error) {
  // log error
  return { type: 'Err', error: error.message }
}
Enter fullscreen mode Exit fullscreen mode

That’s was great upgrade our code no longer explodes unexpectedly, and if we parse JSON, and it works, we get the types we expect, but if the JSON fails to parse, or we get the wrong types, the function returns an Error with why inside. This in turn ensures those using that function are forced to handle the unhappy path, else their code won’t compile. Because we used a discriminated Union, their code is ensured to safely access only the data in happy paths, and only the error in unhappy paths.

const result = parsePeople(...)
if(result.type === 'Ok') {
  console.log("people:", result.data)
} else {
  console.log("error:", result.error)
}
Enter fullscreen mode Exit fullscreen mode

Testing, Code Coverage, and Better Logging Insight

However, 3 problems remain that we can solve to make the types better.

First, it’s a bit obtuse to test; you have to pass in both bad JSON, or valid JSON but the wrong type to get the 2 unhappy paths. The unhappy paths are both handled, but it’s not clear from the return type what can go wrong; you have to read the code so you know what string inputs to make the types match up. This can lead to bad habits of asserting on strings in unit tests to validate happy paths. If you used types, you wouldn’t have to write those tests, at least, assert on types that compiler can help with vs. magic strings. Our Err has a string inside, but a string can be anything, changed later, and the compile won’t tell you, only the unit tests will. While good, that makes the tests brittle to changes.

Second, doing the above can result in bad code coverage; send in bad JSON and call it a day vs. handle the type decoding problem. Easier to skip this step because the first makes it hard.

Finally, while I’m calling this logging insight, there is an assumption in these types that nothing can be done with the 2 different problematic situations of bad JSON, or incorrect shape JSON. Some users of this function might need to know that, but currently have no way to do so. Whether front-end or back-end, errors are logged to observability platforms like New Relic or Splunk, alerts are then setup for those errors, and your phone rings at 3am. You’ll thank yourself when the errors are clearly marked vs. “Go look at the mangled stack trace in the JSON when you’re half awake and everyone is panicking.”

We can fix all 3 by changing the return type of undefined to “Here’s what happened”. Types are, in part, built to help narrow things down. So let’s define a type indicating 1 of these 3 things can happen:

type ParsePeopleResult
  = Success<Array<Person>>
  | FailedToParse<string>
  | DecodingError<string>
Enter fullscreen mode Exit fullscreen mode

This is a single type that the function returns. Another option is to include those 2 errors in the Result’s Err, but typically Result.Err is treated as just a string, and if you want more fine grained error handling, you make your own type.

The 1st two problems we can fix using this type in our unit tests to make it more clear. The happy path:

it('should work with good JSON', () => {
  const { type } = parsePeople(JSON.stringify({ name: 'Jesse' })
  expect(type).toBe('Success')
})
Enter fullscreen mode Exit fullscreen mode

The bad JSON unhappy path:

it('should handle bad JSON', () => {
  const { type } = parsePeople('🐄')
  expect(type).toBe('FailedToParse')
})
Enter fullscreen mode Exit fullscreen mode

And finally the bad type unhappy path:

it('should handle bad data', () => {
  const { type } = parsePeople(JSON.stringify([1, 2, 3]))
  expect(type).toBe('DecodingError')
})
Enter fullscreen mode Exit fullscreen mode

Finally, for both logging concerns as well as given the developer who is consuming the function the opportunity to possibly handle the unhappy paths in their code, we can switch, also called “pattern matching”, on the type. Unlike an Enum, which also supports pattern matching, our Discriminated Union also contains the error message inside of it, helping give us additional context around the error if you need more information.

const result = parsePeople(...)
switch(result.type) {
  case 'Success':
    ...
  case 'FailedToParse':
    logger(`Failed to Parse JSON, reason: ${result.error}`)
  case 'DecodingError
    logger(`Failed to decode the successfully parsed JSON to an Array of Person's, reason: ${ersult.error}`) 
Enter fullscreen mode Exit fullscreen mode

Now, our parsePerson function can be updated with the following signature:

const parsePerson = (jsonString:string):ParsePeopleResult => 
Enter fullscreen mode Exit fullscreen mode

… and it doesn’t lie.

Conclusions

As you can see, TypeScript’s strength of allowing you to gradually type your code as you learn, or are happy with a certain level of type safety, can also be dangerous as the types can imply something that isn’t true. Again, this is a pro as it can allow your team to make progress and add more strict types later if you wish, or as you learn your domain more. However, it comes with a tradeoff of intentionally allowing your code to not be as type safe.

Embracing errors as return values instead of throwing Exceptions can drastically improve predictability of your code, and help TypeScript cover a huge swath of your code to ensure it won’t crash unknowingly after you compile and run it.

Leveraging type narrowing helper libraries like Zod or ArcType can significantly reduce the type narrowing you need to do know parsing outside data, reduce lots of boilerplate type narrowing code, and increase type safety. Many of the type lies TypeScript tells are from JSON.parse, converting Errors typed as unknown, or when reading local configuration files. This is where Zod/ArcType can really make a huge positive impact.

Anywhere you see an any, unknown, and most especially the as keyword where the programmer is making an assertion they know better than the compiler, be extra wary of these 3 as their the low-hanging fruit in making your code safer.

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

AWS Security LIVE!

Tune in for AWS Security LIVE!

Join AWS Security LIVE! for expert insights and actionable tips to protect your organization and keep security teams prepared.

Learn More

👋 Kindness is contagious

If this article connected with you, consider tapping ❤️ or leaving a brief comment to share your thoughts!

Okay