DEV Community

Cover image for Typescript: It's not actually validating your types.
Red Ochsenbein (he/him)
Red Ochsenbein (he/him)

Posted on • Updated on • Originally published at ochsenbein.red

Typescript: It's not actually validating your types.

Typescript is a nice thing: It lets you define types and make sure your classes and functions adhere to certain expectations. It forces you to think about what data you put into a function and what you will get out of it. If you get that wrong and try to call a function which expects a sting with a - let's say - number, the compiler will let you know. Which is a good thing.

Sometimes this leads to a misconception: I met people who believed typescript would make sure the types are what you say you are. But I have to tell you: Typescript does not do that.

Why? Well, Typescript is working on compiler level, not during the runtime. If you take a look at how the code Typescript produces does look like you'll see it translates to Javascript and strips all the types from the code.

Typescript code:

const justAFunction = (n: number): string => {
  return `${n}`
}

console.log(justAFunction)
Enter fullscreen mode Exit fullscreen mode

The resulting Javascript code (assuming you are transpiling to a more recent EcmaScript version):

"use strict";
const justAFunction = (n) => {
    return `${n}`;
};
console.log(justAFunction);
Enter fullscreen mode Exit fullscreen mode

It only checks if the types seem to be correct based on your source code. It does not validate the actual data.

Checking types

Is typescript useless then? Well, no, far from it. When you use it right it forces you to check your types if there are no guarantees ("unfortunately" it also provides some easy ways out).

Let's change our example a little bit:

const justAFunction = (str: string[] | string): string => {
  return str.join(' ') 
}

console.log(justAFunction(["Hello", "World"]))
console.log(justAFunction("Hello World"))
Enter fullscreen mode Exit fullscreen mode

When compiling this will lead to the following error:

index.ts:2:14 - error TS2339: Property 'join' does not exist on type 'string | string[]'.
  Property 'join' does not exist on type 'string'.

2   return str.join(' ')
               ~~~~


Found 1 error in index.ts:2
Enter fullscreen mode Exit fullscreen mode

The compiler forces to think about the type of the variable str. One solution would be to only allow string[] into the function. The other is to test if the variable contains the correct type.

const justAFunction = (str: string[] | string): string => {
  if (typeof str === 'string') {
    return str
  }

  return str.join(' ') 
}

console.log(justAFunction(["Hello", "World"]))
console.log(justAFunction("Hello World"))
Enter fullscreen mode Exit fullscreen mode

This would also translate into Javascript and the type would be tested. In this case we would only have a guarantee that it is a string and we would only be assuming it is an array.

In many cases this is good enough. But as soon as we have to deal with external data source - like APIs, JSON files, user input and similar - we should not assume the data is correct. We should validate the data and there is an opportunity to ensure the correct types.

Mapping external data to your types

So the first step to solve this problem would probably be to create actual types to reflect your data.

Let's assume the API returns a user record like this:

{
  "firstname": "John",
  "lastname": "Doe",
  "birthday": "1985-04-03"
}
Enter fullscreen mode Exit fullscreen mode

Then we may want to create an interface for this data:

interface User {
  firstname: string
  lastname: string
  birthday: string
}
Enter fullscreen mode Exit fullscreen mode

And use fetch to retrieve the user data from the API:

const retrieveUser = async (): Promise<User> => {
  const resp = await fetch('/user/me')
  return resp.json()
}
Enter fullscreen mode Exit fullscreen mode

This would work and typescript would recognize the type of the user. But it might lie to you. Let's say the birthday would contain a number with the timestamp (might be somewhat problematic for people born before 1970... but that's not the point now). The type would still treat the birthday as a string despite having an actual number in it... and Javascript will treat it as a number. Because, as we said, Typescript does not check the actual values.

What should we do now. Write a validator function. This might look something like this:

const validate = (obj: any): obj is User => {
  return obj !== null 
    && typeof obj === 'object'
    && 'firstname' in obj
    && 'lastname' in obj
    && 'birthday' in obj
    && typeof obj.firstname === 'string'
    && typeof obj.lastname === 'string'
    && typeof obj.birthday === 'string'
}

const user = await retrieveUser()

if (!validate(user)) {
  throw Error("User data is invalid")
}
Enter fullscreen mode Exit fullscreen mode

This way we can make sure the data is, what it claims to be. But you might see this can get quickly get out of hands in more complex cases.

There are protocols inherently dealing with types: gRPC, tRPC, validating JSON against a schema and GraphQL (to a certain extend). Those are usually very specific for a certain usecase. We might need a more general approach.

Enter Zod

Zod is the missing link between Typescript's types and enforcing the types in Javascript. It allows you to define the schema, infer the type and validate the data in one swipe.

Our User type would be defined like this:

import { z } from 'zod'

const User = z.object({
    firstname: z.string(),
    lastname: z.string(),
    birthday: z.string()
  })
Enter fullscreen mode Exit fullscreen mode

The type could then be extracted (inferred) from this schema.

const UserType = z.infer<User>
Enter fullscreen mode Exit fullscreen mode

and the validation looks like this

const userResp = await retrieveUser()
const user = User.parse(userResp)
Enter fullscreen mode Exit fullscreen mode

Now we have a type and validated data and the code we had to write is only marginally more than without the validation function.

Conclusion

When working with Typescript it is important to know the difference between compiler checks and runtime validation. To make sure external data are conforming to our types we need to have some validation in place. Zod is great tool to deal with exactly that without much overhead and in a flexible way.

Thanks for reading.

Top comments (19)

Collapse
 
joelbonetr profile image
JoelBonetR 🥇

Nice one!

I use Joi for this kind of validations, I may take a try to Zod to see the differences and which one is more convenient depending on the use-case.

Thank you for sharing! 😁

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him) • Edited

Joi can do a lot more than Zod (like dealing with linked references, etc.) and is much closer to JSON schemas, this might be a great thing to have for really complex cases. But I'd say Zod being simpler makes it more suitable for many smaller to midsize projects which don't have to deal with that much complexity. (Oh, and Joi is quite a few years older than Zod)

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him) • Edited

Thanks for commenting and suggesting io-ts. Just looked into it. I see how it works, but in contrast to zod it feels a bit "academic" which might put off some people.

Collapse
 
qm3ster profile image
Mihail Malo

but what if "academic = good, some people = bad"

Thread Thread
 
syeo66 profile image
Red Ochsenbein (he/him)

Well, I think this is an excluding opinion. But sure, if you have the time and resources to teach everyone in your team... more power to you.

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him) • Edited

Hm, works only on classes. This makes it rather limiting (especially for people tending towards a more functional style.)

(Update: Just realized there is also a way to work without those decorators and validate plain objects. But it feels rather cumbersome. I think I prefer the Zod way, and for more complex stuff I'd probably use Joi)

Collapse
 
elsyng profile image
Ellis

To my knowledge & experience, type safety is typically (much) less important in a frontend app than in a backend app.

Meaning: we don't need to obsess about it, or spend effort into it, nearly as much. Relatively speaking. A lot of the static or run-time type checking might be unjustified and overkill.

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him) • Edited

And then clients wonder why they only see a white screen...

Seriously, yeah, browsers are usually quite forgiving. Which can be a blessing and a curse. For the "curse" side we do have those tools which help us make things more deterministic and make us really think. This helps a lot in creating robust applications with proper error handling by making us aware of some possible edge cases. But you are right, the backend side should (!) be way more rigorous all the time. Unfortunately I saw way to many instances of neglect in the vain of "let's fix that in the frontend"... which is not a good thing to do... but at least the frontend catches those things by being more pedantic than it has to be.

Oh and also... my article wasn't targeting the frontend or backend side specifically.

Collapse
 
paulwababu profile image
paulsaul621

I agree, TypeScript is not actually validating your types. It's simply checking that the types you've written down match the types of the variables you're using. If you assign a string to a variable that's meant to hold an integer, for example, TypeScript will give you a warning, but it won't stop you from running the code.

Collapse
 
boutell profile image
Tom Boutell

Good article on a common confusion about TypeScript. I must be missing something here:

const UserType = t.infer<User>
Enter fullscreen mode Exit fullscreen mode

Should "t" be "z" here or is it coming from somewhere else?

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him)

Oops. Typo. Yes sould be z

Collapse
 
zhouyg profile image
zhou-yg

nice point.It's really different between static compiler and dynamic runtime, lots developers confused with them sometimes

Collapse
 
thi3rry profile image
Thierry Poinot

Have you try objectmodel ? objectmodel.js.org/

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him)

No, never heard of it before. At first glance it seems to be quite interesting, but also trying to do a bit too much for my taste. But it certainly seems to be powerful.

Collapse
 
csaltos profile image
Carlos Saltos

This is one of many reasons why at my company we replaced the old TypeScript by the Elm language ... we have a lot of peace and joy coding now 👍😎

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him)

While elm is quite a nice language I would be worried using it in production. It being stale since 2019 and being mostly 'maintained' by one person is quite a risk.
Having said that: You'd have the exact same problem in elm, too. Elm is not validating the types in the runtime. Sure the Maybe monad helps but under the hood it still does not make sure an expected string from an external source is not actually - let's say - a number.

Collapse
 
qm3ster profile image
Mihail Malo • Edited

Someone really likes their return statements, huh? :v

const justAFunction = (n: number): string => `${n}`
Enter fullscreen mode Exit fullscreen mode
const beans = {
  string: (x: string) => x,
  object: (x: string[]) => x.join(' ')
} as const;
const justAFunction = (str: string[] | string): string => beans[typeof str](str)
Enter fullscreen mode Exit fullscreen mode
// no need for `Promise` here, btw, the `async` keyword takes care of it. In fact, that would never be correct, as they get "unwrapped" recursively
const retrieveUser = async (): User => (await fetch('/user/me')).json()
Enter fullscreen mode Exit fullscreen mode

or

const retrieveUser = (): Promise<User> => fetch('/user/me').then(x => x.json())
Enter fullscreen mode Exit fullscreen mode
const validate = (obj: any): obj is User => obj !== null 
    && typeof obj === 'object'
    && 'firstname' in obj
    && 'lastname' in obj
    && 'birthday' in obj
    && typeof obj.firstname === 'string'
    && typeof obj.lastname === 'string'
    && typeof obj.birthday === 'string'
Enter fullscreen mode Exit fullscreen mode

or, a decent pattern instead of is guards:

const yeet = (err: string | Error) => {
  throw typeof err === string
    ? new Error(err)
    : err
}
const validate = (obj: any): User => obj !== null 
    && typeof obj === 'object'
    && 'firstname' in obj
    && 'lastname' in obj
    && 'birthday' in obj
    && typeof obj.firstname === 'string'
    && typeof obj.lastname === 'string'
    && typeof obj.birthday === 'string'
    && obj as User
    || yeet(`expected User, got ${obj} 🐴`)
Enter fullscreen mode Exit fullscreen mode

Nice Zod you got there tho, unironically

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him) • Edited

I can write less verbose code. Easy. But especially when writing an article I tend to be rather be explicit and simple than short and clever. Especially when you think about the scope of an article...

All this to say: I'm usually quite intentional in how I write what.

Collapse
 
qm3ster profile image
Mihail Malo

Nothing about terseness, I just have statement-phobia.