Execute Program

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 message property 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. The unknown type 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'.Pass Icon
  • An unknown isn't very useful on its own. But with some work, we can safely bring our unknown value 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 a message property?" 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 return undefined instead.

  • 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 to object, then we tried to access parsed.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'Pass Icon
  • This is an unfortunate part of JavaScript, but we have to deal with it. typeof parsed === 'object' doesn't mean that parsed is what we normally think of as an object, like {} or {name: 'Amir'}. It might be null. This narrows parsed to object | null, which explains our type error above.

  • The next example is the same as the previous version, but we've added a parsed !== null check to make sure that parsed really 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 parsed is a real, non-null object, but it doesn't know what properties that object has.

  • Now we need to check for whether the message property exists. JavaScript has an in operator 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 the message property is still unknown. The final step is to check message's type. Another typeof type 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 get undefined.

  • 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.'}Pass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    parseCommentJson('123');
    Result:
    undefinedPass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    parseCommentJson('{"text": "Now you have two problems."}');
    Result:
    undefinedPass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    parseCommentJson('{"message": {"message": "Now you have two problems."}}');
    Result:
    undefinedPass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    parseCommentJson('{"error": "Service temporarily unavailable."}');
    Result:
    undefinedPass Icon
  • 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 message property 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:

    1. We can choose to have no safety at all. In TypeScript, this means using any, or forcing the type with parsed as Comment. If the runtime data is wrong, we won't know, just as we wouldn't know in JavaScript.
    2. 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!)
    3. 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.
  • 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 any or as to 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. Each if acted as a type guard for the next if. 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 one if, 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 of if conditionals.

  • Finally, a note about safety when working with JSON in particular. Unfortunately, TypeScript's type definitions for JSON.parse have a return type of any. That's because old versions of TypeScript didn't have the unknown type at all, and changing JSON.parse's type now would break a lot of code. This makes JSON.parse dangerous, because that any defeats the type system. When using JSON.parse, it's best to explicitly specify the unknown type, like const parsed: unknown = JSON.parse(json).