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:
- Chapter 5:
Number("banana")givesNaN, and it prints like an answer. - Chapter 10:
scores[10]is typednumberand holdsundefined. - Chapter 14:
.find()is honest about failing, indexing isn’t. - Chapter 16: a bad value got clamped, and you were told that was a decision rather than the answer.
- Chapter 17:
JSON.parsereturnsany, and annotating it is a promise nobody checked. - Chapter 18:
.get()might not find anything.
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 | undefinedthat 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
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 TaskThen rewrite loading so that:
JSON.parsegoes into anunknown, never aTask[].- A file that isn’t an array gives a clear message and an empty list.
- Each entry is checked, kept if good, and reported to
console.errorif not. - 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 shapenot 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.