Advanced TypeScript: Validating Data Manually
Welcome to the Validating Data Manually lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
In any static language, we'll eventually find ourselves dealing with data from outside systems. Maybe we're accessing a third party API. Maybe we're implementing our own API server, so we have to handle incoming API data from clients. Maybe we're loading some data from a JSON file on disk.
In each of these cases, we don't know whether the data has the shape that we expect. If we expect an object, did we actually get an object? Or did we get an array or a number or a string? Does the object have the properties that we expect? If we expect the
messageproperty to be a string, is it actually a string, or is it something else?How do we statically type data when we don't know whether it matches our expected types? We could use
any, but we've seen that this gives us no static guarantees at all. Theunknowntype is better: TypeScript stops us from using the value directly.>
const json = '{"message": "Now you have two problems."}';const parsed: unknown = JSON.parse(json);const text: string = parsed.message;text;Result:
type error: 'parsed' is of type 'unknown'.
An
unknownisn't very useful on its own. But with some work, we can safely bring ourunknownvalue into the world of static types. We'll use a series of dynamic checks like "is this value actually an object?" and "does the object have amessageproperty?" and "is the property's value actually a string?"A warning up front: this will be awkward. At the end of this lesson, we'll mention some libraries designed to do this same job without so much manual work. Still, it's important to see the manual way. It shows how TypeScript really works, which will help you to understand what happens in real-world code.
For our example, we'll parse a simple "comment" object stored in a JSON string like
'{"message": "Now you have two problems."}'. That JSON matches the static type{message: string}, so we want to give it that type. But if it doesn't match that type, we want to returnundefinedinstead.To get started, we first need to decide whether the JSON data is even an object.
>
type Comment = {message: string};function parseCommentJson(json: string): Comment | undefined {const parsed: unknown = JSON.parse(json);if (typeof parsed === 'object') {return {message: parsed.message};}return undefined;}Result:
We narrowed
parsed's type toobject, then we tried to accessparsed.message. It would make sense to get a type error like "Property 'message' does not exist." Instead, we get "'parsed' is possibly 'null'". Why?The reason is that
typeof null === 'object'in both JavaScript and TypeScript.>
typeof null;Result:
'object'
This is an unfortunate part of JavaScript, but we have to deal with it.
typeof parsed === 'object'doesn't mean thatparsedis what we normally think of as an object, like{}or{name: 'Amir'}. It might benull. This narrowsparsedtoobject | null, which explains our type error above.The next example is the same as the previous version, but we've added a
parsed !== nullcheck to make sure thatparsedreally is an object. Now we get the "property does not exist" error message that we expected.>
type Comment = {message: string};function parseCommentJson(json: string): Comment | undefined {const parsed: unknown = JSON.parse(json);if (typeof parsed === 'object') {if (parsed !== null) {return {message: parsed.message};}}return undefined;}Result:
That's progress: TypeScript now knows that
parsedis a real, non-null object, but it doesn't know what properties that object has.Now we need to check for whether the
messageproperty exists. JavaScript has aninoperator for that purpose, and fortunately it works as a type guard.>
type Comment = {message: string};function parseCommentJson(json: string): Comment | undefined {const parsed: unknown = JSON.parse(json);if (typeof parsed === 'object') {if (parsed !== null) {if ('message' in parsed) {return {message: parsed.message};}}}return undefined;}Result:
We're successfully accessing
parsed.message, but themessageproperty is stillunknown. The final step is to checkmessage's type. Anothertypeoftype guard achieves that.>
type Comment = {message: string};function parseCommentJson(json: string): Comment | undefined {const parsed: unknown = JSON.parse(json);if (typeof parsed === 'object') {if (parsed !== null) {if ('message' in parsed) {if (typeof parsed.message === 'string') {return {message: parsed.message};}}}}return undefined;}That version works! When the JSON data matches our expectations, we get the
{message: ...}object back. When it doesn't match, we getundefined.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
parseCommentJson('{"message": "Now you have two problems."}');Result:
{message: 'Now you have two problems.'} - Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
parseCommentJson('123');Result:
undefined
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
parseCommentJson('{"text": "Now you have two problems."}');Result:
undefined
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
parseCommentJson('{"message": {"message": "Now you have two problems."}}');Result:
undefined
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
parseCommentJson('{"error": "Service temporarily unavailable."}');Result:
undefined
That was a lot of work, and our function is quite awkward. But it's important to appreciate what we did here. We handled four different error cases, all of which can happen in reality:
- The JSON value might not be an object at all.
- It might be
null, which is an "object" in JavaScript, but not the kind of object we expect. - It might not have the
messageproperty that we expect. - It might have a
message, but with an unexpected type.
We could write those checks in any language, including regular JavaScript. The benefit of using TypeScript is that the compiler showed us each of these potential problems, leading us to write type guards for each possibility.
Regardless of our programming language, we have to choose the level of safety that we want when validating outside data. Here are some common options:
- We can choose to have no safety at all.
In TypeScript, this means using
any, or forcing the type withparsed as Comment. If the runtime data is wrong, we won't know, just as we wouldn't know in JavaScript. - We can implement checks manually, like we did in this lesson. This gives us good confidence, but requires a lot of extra code. (Imagine repeating this process for a hundred complex API endpoints, many of which have deeply nested objects!)
- We can implement checks with a third-party data validation library like Zod or io-ts. This gives us even more confidence than we get with our manual checks, and it requires less code. The trade-off is that we have to learn how to use the library.
- We can choose to have no safety at all.
In TypeScript, this means using
The best option will depend on your project and your team. Are you writing code for a financial application, or for a healthcare system, or for authentication and authorization of users? If so, you probably want very strict, formalized checks on all data. Are you writing an internal analytics dashboard, where bugs won't be visible to customers and can't cause incorrect data to end up in a database? If so, it may be fine to use
anyorasto skip the data validation step.A note about our syntax in this lesson. The final version of our code contained four levels of nested
ifs. Eachifacted as a type guard for the nextif. After the fourth type guard, our object was dynamically checked, and its static type was also fully narrowed.We wrote the
ifs in that way because it made our incremental changes easier to follow. However, TypeScript is quite good at narrowing types, so we could've written our code with only oneif, like this:>
if (typeof parsed === 'object' &&parsed !== null &&'message' in parsed &&typeof parsed.message === 'string') {return {message: parsed.message};}In each
&&, the expression on the left acts as a type guard for the expression on the right. From a type perspective, the chain of&&s acts like a series ofifconditionals.Finally, a note about safety when working with JSON in particular. Unfortunately, TypeScript's type definitions for
JSON.parsehave a return type ofany. That's because old versions of TypeScript didn't have theunknowntype at all, and changingJSON.parse's type now would break a lot of code. This makesJSON.parsedangerous, because thatanydefeats the type system. When usingJSON.parse, it's best to explicitly specify theunknowntype, likeconst parsed: unknown = JSON.parse(json).