This article is about improving the solution shared by Michal Szorad in his TypeScript: Keeping Type Guards Safe and Up To Date article.
Photo by Brian McGowan on Unsplash
Type guards allow us to check that a runtime, unknown object (think of data coming from the back-end) respects a given type. Type guards are nothing more than written-by-developer functions that receive an unknown
parameter, check that all the properties are there, and respect a specific type. Hence, TypeScript can safely assume the parameter is typed correctly.
An example: given this type
interface Person {
name: string
age: number
}
and this is a type guard for the above Person
function isPerson(value: unknown): value is Person {
return (
typeof value === 'object' &&
value &&
value.hasOwnProperty('name') &&
value.hasOwnProperty('age') &&
typeof value.name === 'string' &&
typeof value.age === 'number'
)
}
What is the main problem with the above type guard? It does not scale. If you update the Person
type, TypeScript does not throw. Imagine changing the type to
interface Person {
name: string
age: number
something: string // <-- a new property
}
TypeScript does not complain, but our isPerson
function is now outdated.
Michal Szorad already discussed this problem in his TypeScript: Keeping Type Guards Safe and Up To Date article (I stole the title, sorry, Michal) and proposed a scaling working solution.
Please read his article to understand the problem and its solution fully. This article uses his approach with some slight differences that come to a more straightforward solution (in my opinion). The differences are:
- Using an
isPlainObject
type guard instead of extending theglobal.object
- Getting
isPerson
back to an actual type guard
1. Using an isPlainObject
type guard instead of extending the global.object
I avoid extending TypeScript's globals as much as I can. The following isPlainObject
is a simple workaround that allows TypeScript to recognize that properties checked through hasOwn
/hasOwnProperty
are effectively part of the object itself but without extending global.object
.
interface PlainObject {
hasOwnProperty<K extends string>(key: K): this is Record<K, unknown>
// Object.hasOwn() is intended as a replacement for Object.hasOwnProperty(). See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwn
hasOwn<K extends string>(key: K): this is Record<K, unknown>
}
function isPlainObject(value: unknown): value is PlainObject {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
2. Getting isPerson
back to an actual type guard
Michal proposed to create a parser, then use it to create a type guard. My proposal is to leverage either the parser's perks (being a type guard that scales) and the standard type guard ones (the simplicity).
This is the result of mixing the two approaches:
function isPerson(value: unknown): value is Person {
if (!isPlainObject(value)) return false
if (!value.hasOwnProperty('name')) return false
if (!value.hasOwnProperty('age')) return false
const { name, age } = value
if (typeof name !== 'string') return false
if (typeof age !== 'number') return false
// @ts-expect-error: turn off "obj is declared but never used."
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const obj: Person = { name, age }
return true
}
Most of the magics come from the following lines
// @ts-expect-error: turn off "obj is declared but never used."
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const obj: Person = { name, age }
This is necessary because I did not like Michal's solution to create and return a new object every time. I am used to working on an application that heavily uses the machine's memory, and the fewer objects I create, the better. What is the difference between creating and returning the object instead of just creating it?
Massimiliano Mantione (that worked in the V8 team, the Javascript VM inside the Chrome browser) bets that the code related to a non-consumed variable—a variable that is not passed to other functions and that is not returned, so it does not leave the function scope and is not shared with other scopes—is removed at compile time because, from a runtime perspective, it's completely useless. Hence, the object is not created at runtime, it does not pass through the garbage collector, or its nursery.
You can also play with the above example in this TypeScript playground, where I also added an optional property to show how we could manage it.
Updates
Please take a look at Alexis comment for a little change that helps protecting against new optional properties too 😊
Conclusions
Thanks a lot to Michal Szorad, who opened my eyes with his solution, Matteo Ronchi, which helped me evolve Michal's implementation, and Massimiliano Mantione, that gave me the internals about the JS VM.
Top comments (4)
Very cool, thanks for sharing! I was looking for exactly that and intend to adapt it to assertion functions.
Just a quick note - I'm not sure why those complicated
hasOwnProperty
andhasOwn
types are needed, and I simplified as follows.Would love to hear from you @noriste @alexis what could go wrong with my code.
Uhm, I think you are right, we do not need them...
I really like the approach, however it doesn't alert you if you add new optional properties.
Suggestion to make it more robust: typescriptlang.org/play?#code/PTAE...
I just updated the article pointing to your suggeste change, thank you!! 😊