2021-10-01
|~3 min read
|564 words
I recently had a problem where I wanted to remove Falsy keys from an object. In particular, I wanted to do it in a way that retained my typings.
One of the solutions I talked about referenced “Unsafe keys”. So, what are those?
Let’s look at an example:
type Audio = {
base: string
treble: string
}
function volume(obj: Audio) {
Object.keys(obj).forEach((key) => {
// If key is "base" | "treble", then this compiles:
console.log(obj[key].toUpperCase())
})
}
Here we have defined the API of volume
to accept a type of Audio
.
Typescript, however, doesn’t really know if what you pass it is Audio
in all cases.
Everything works if we adhere to the contract:
const audio: Audio = { base: "ten", treble: "eight" }
volume(audio)
However, if we don’t, Typescript will still compile - however we’ll might get runtime exceptions:
const audio2 = { base: "x", treble: "y", zero: 0 }
volume(audio2)
In this case, we tried to call toUpperCase
on a number.
Why, though, did it compile? audio2
doesn’t comply with the type Audio
.
Because Typescript uses structural typing.
TypeScript is comparing each member in the type against each other to verify their equality.
In our example, audio2
has all of the pieces required by Audio
, so Typescript allows it.
Interestingly, however, Typescript is able to spot the problem in certain cases. For example, if the object is inlined, Typescript will complain:
volume({ base: "five", treble: "two", zero: 0 })
Argument of type '{ base: string; treble: string; zero: number; }' is not assignable to parameter of type 'Audio'.
Object literal may only specify known properties, and 'zero' does not exist in type 'Audio'.
Back to the original point: an unsafe key is one way to describe keys that Typescript allows because it’s uses structural typing, not nominal typing.
This means that we can (and often do) have extra keys, but we’re okay with that.
In my case, I was trying to communicate that a new type should only have a subset of keys, but Object.keys
has the signature string[]
.
Well, we just decided that even if we knew the type of the obj
- it’s totally acceptable to receive additional keys.
My unsafeKeys
function acknowledged this and explicitly stated that we’re expecting an array of keys of T
.
const unsafeKeys = Object.keys as <T>(obj: T) => Array<keyof T>
The unsafe
prefix is an acknowledgement that we might get more than that!
To prove the point, we can run our two audio
objects through the object and see how this works:
const aKeys = unsafeKeys(audio)
const a2Keys = unsafeKeys(audio2)
console.log({ aKeys, a2Keys })
{
"aKeys": [
"base",
"treble"
],
"a2Keys": [
"base",
"treble",
"zero" ]
}
Digging into how this works meant that I learned more about Typescript’s typing system and that was another layer of the onion that I needed in order to fully understand how and why things works in Typescript
I was also encouraged to “not stress” about this so much and that it’s quite common for these utility functions to have “unsafe” implementations that rely on casting or any
as there are many object manipulation operations which cannot be “safely” implemented for any number of reasons.
Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!