Contents

Chapter 19

Validation: Assuming the Worst

unknown, optional chaining, and narrowing.

Six chapters have pointed here, which is more than any other chapter in this book. Worth listing, because they’re all the same problem:

Every one is the same shape: something arrived from outside, and your types were a hopeful description of it.

any versus unknown

Chapter 3 met any, the opt-out. Here’s what it actually costs:

const a: any = "hello";

console.log(a.toUpperCase());
console.log(a.nonsense.at.all);

Both lines compile. The second one is obvious nonsense and TypeScript has switched off, so it says nothing. That’s any: not “I don’t know what this is”, but “stop checking”.

There’s a second type for the honest version:

const u: unknown = "hello";
console.log(u.toUpperCase());
error TS18046: 'u' is of type 'unknown'.

unknown also means “could be anything”, and it refuses to let you do anything at all with the value until you’ve established what it is.

That’s the difference, and it’s the whole chapter in two lines. any says trust me. unknown says prove it.

Narrowing

Proving what something is means asking. TypeScript watches the questions you ask and updates what it thinks you have:

function describe(u: unknown): string {
  if (typeof u === "string") return `string of length ${u.length}`;
  if (typeof u === "number") return `number ${u.toFixed(1)}`;
  if (Array.isArray(u)) return `array of ${u.length}`;
  if (u === null) return "null";
  return `something else: ${typeof u}`;
}

console.log(describe("hi"));
console.log(describe(3));
console.log(describe([1, 2]));
console.log(describe(null));
console.log(describe({ a: 1 }));
string of length 2
number 3.0
array of 2
null
something else: object

Look at u.length on the first line. That would be an error on an unknown, and inside the if it’s fine, because typeof u === "string" proved it. That’s narrowing: the type of u is different in different parts of the function, decided by what you’ve checked.

Array.isArray is the one to remember for arrays, because typeof [] is "object", which tells you almost nothing. And null needs its own check for the same reason: typeof null is also "object", a famous JavaScript wart that has been left alone for thirty years because fixing it would break the web.

When something might not be there

Chapter 12’s objects can have properties that are sometimes absent, marked with a ?:

type Book = { title: string; author?: { name: string } };

const a: Book = { title: "Dune" };
console.log(a.author.name);
error TS18048: 'a.author' is possibly 'undefined'.

TypeScript caught it. The fix could be an if, and for one property that’s fine, but chains get tedious fast. So there’s a shorter way:

console.log(a.author?.name);
undefined

?. is optional chaining: if the thing on the left is null or undefined, stop and give back undefined instead of reaching further. Otherwise carry on. It chains as far as you like, and any link failing ends the whole thing quietly.

Choosing a fallback

undefined is rarely what you want to print, so pair it with ??:

console.log(a.author?.name ?? "unknown");
unknown

?? is nullish coalescing: use the left side unless it’s null or undefined, in which case use the right.

You may have seen || used for the same job, and it is not the same job.

Checking a whole shape

Now chapter 17’s problem, properly. That chapter parsed a file, annotated it Task[], and the annotation was a claim nobody verified.

You can write the verification yourself:

type Task = { title: string; done: boolean };

function isTask(value: unknown): value is Task {
  if (typeof value !== "object" || value === null) return false;
  const c = value as Record<string, unknown>;
  return typeof c.title === "string" && typeof c.done === "boolean";
}

The return type is the new part. value is Task says: this function returns a boolean, and when it returns true, TypeScript should treat the argument as a Task from then on. That’s a type predicate, and it’s how you teach the compiler a check it couldn’t work out alone.

Inside, it’s ordinary code. Rule out non-objects and null, then look at each property with typeof.

Now the loader:

function loadTasks(text: string): Task[] {
  const parsed: unknown = JSON.parse(text);

  if (!Array.isArray(parsed)) {
    console.error("Not a list. Starting empty.");
    return [];
  }

  const good: Task[] = [];
  for (const item of parsed) {
    if (isTask(item)) {
      good.push(item);
    } else {
      console.error("Skipping a bad entry:", JSON.stringify(item));
    }
  }
  return good;
}
Skipping a bad entry: {"title":"Bad","done":"yes"}
Skipping a bad entry: {"nothing":1}
[ { title: 'Real', done: false } ]

Note const parsed: unknown. Chapter 17 wrote : Task[] there and got a lie. Writing unknown instead means TypeScript won’t let you touch it until you’ve checked, and by the time good is returned, its type is earned rather than asserted.

The complaints go to console.error, which chapter 11 introduced and chapter 17 showed you how to redirect. A program that skips bad data silently is worse than one that crashes.

Closing chapter 10’s hole

One thing left. Chapter 10 showed scores[10] typed number while holding undefined, and said it was the one place the type system simply lies.

There’s a switch for it:

npx tsc --noUncheckedIndexedAccess scores.ts
error TS2322: Type 'number | undefined' is not assignable to type 'number'.

With that flag on, every array index gives you T | undefined, and you have to check before using it. The lie is gone.

It’s off by default because turning it on in an existing codebase produces hundreds of errors, most of them on indexes that were genuinely fine. That’s a real cost, and it’s why it’s a choice rather than the rule.

Exercise 1 · Validate a number from input

Chapter 5’s mad-libs read a number with Number(lines[1]) and never checked it. Write

function toCount(text: string): number | undefined

that returns the number if the text really is one, and undefined if it isn’t. Number.isNaN() is the check, and note that NaN === NaN is false, so comparing it directly never works.

Then use it with ?? to supply a sensible default, and confirm that feeding the program banana no longer puts NaN in your output.

Check yourself

1. What is the practical difference between any and unknown?

Not quite. Both accept any value. The difference is what you can do with one afterwards.

Yes. any means stop checking, unknown means prove it. That is why unknown belongs at every boundary.

Not quite. It fits there well, but it is an ordinary type you can use anywhere a value has not been established yet.

2. const n: number | undefined = 0; What does n || "none" give you?

Not quite. That is what ?? gives. || falls back on any falsy value, and 0 is falsy.

Yes. A legitimate zero got thrown away. Use ?? when you mean "if this is missing" rather than "if this is falsy".

Not quite. It compiles fine, which is exactly why this bug is easy to write and hard to spot.

3. What does the return type value is Task do?

Not quite. The function returns a boolean. The predicate describes what that boolean means.

Yes. A type predicate teaches the compiler a check it could not work out alone, so narrowing works on your own validation.

Not quite. Nothing is converted. The value is whatever it was; the predicate only reports on it.

Project

A to-do list that cannot be broken

Roughly 50 minutes

Chapter 17’s project ended by having you corrupt tasks.json in an editor and watch the program misbehave. Now fix it properly.

Start from that program. If you changed done to "yes" and never changed it back, leave it, you have a test case already.

Write the validator.

function isTask(value: unknown): value is Task

Then rewrite loading so that:

  1. JSON.parse goes into an unknown, never a Task[].
  2. A file that isn’t an array gives a clear message and an empty list.
  3. Each entry is checked, kept if good, and reported to console.error if not.
  4. The function returns a Task[] you have genuinely earned.

Then attack it. Put each of these in tasks.json by hand and run the program. None should crash, and every one should tell you what it did:

  • [{"title":"Fine","done":false}], the normal case
  • [{"title":"Bad","done":"yes"}], a wrong field type
  • [{"nothing":1}], missing fields entirely
  • {"not":"a list"}, right JSON, wrong shape
  • not json at all, which is the interesting one

That last one is different. JSON.parse doesn’t return anything useful on malformed text, it throws, the way chapter 17’s missing file did. Your existsSync check doesn’t help, because the file exists and its contents are nonsense.

Handling a thrown error needs try/catch, which this book hasn’t taught. Look it up, the shape is short, and use it to turn that crash into the same clear message as the others. Writing your first one by choice, on a problem you already understand, is a better introduction than a chapter would have been.

The finished test: every one of those five files leaves you with a working program, a sensible message, and no crash. Then put a good file back and confirm normal operation still works. Defensive code that breaks the normal case is not an improvement.

Stretch: keep a count of how many entries were skipped and report it once at the end rather than per line, so a badly corrupted file gives you one summary rather than four hundred lines of complaint. Then decide whether a file with some bad entries should still be saved back out, and notice that saving quietly deletes the entries you skipped.