Why doesn’t TypeScript properly type Object.keys?
If you’ve written TypeScript for a while, you’ve probably run into this:
interface Options {hostName: string;port: number;}function validateOptions (options: Options) {Object.keys(options).forEach(key => {if (options[key] == null) {// Expression of type 'string' can't be used to index type 'Options'.throw new Error(`Missing option ${key}`);}});}
This error seems nonsensical. We’re using the keys of
options
to access
options
. Why doesn’t TypeScript just figure this out?
We can somewhat trivially circumvent this by casting
Object.keys(options)
to
(keyof typeof options)[]
.
const keys = Object.keys(options) as (keyof typeof options)[];keys.forEach(key => {if (options[key] == null) {throw new Error(`Missing option ${key}`);}});
But why is this a problem in the first place?
If we visit the type definition for
Object.keys
, we see the following:
// typescript/lib/lib.es5.d.tsinterface Object {keys(o: object): string[];}
The type definition is very simple. Accepts
object
and returns
string[]
.
Making this method accept a generic parameter of
T
and return
(keyof T)[]
is very easy.
class Object {keys<T extends object>(o: T): (keyof T)[];}
If
Object.keys
were defined like this, we wouldn’t have run into the type error.
It seems like a no brainer to define
Object.keys
like this, but TypeScript has a good reason for not doing so. The reason has to do with TypeScript’s
structural type system
.
Structural typing in TypeScript
TypeScript complains when properties are missing or of the wrong type.
function saveUser(user: { name: string, age: number }) {}const user1 = { name: "Alex", age: 25 };saveUser(user1); // OK!const user2 = { name: "Sarah" };saveUser(user2);// Property 'age' is missing in type { name: string }.const user3 = { name: "John", age: '34' };saveUser(user3);// Types of property 'age' are incompatible.// Type 'string' is not assignable to type 'number'.
However, TypeScript does not complain if we provide extraneous properties.
function saveUser(user: { name: string, age: number }) {}const user = { name: "Alex", age: 25, city: "Reykjavík" };saveUser(user); // Not a type error
This is the intended behavior in structural type systems. Type
A
is assignable to
B
if
A
is a superset of
B
(i.e.
A
contains every property in
B
).
However, if
A
is a
proper
superset of
B
(i.e.
A
has
more
properties than
B
), then
A
is assignable to
B
, but
B
is not assignable to
A
.
Note: In addition to needing to be a superset, property-wise, the types of the properties also matter.
This is all quite abstract, so let’s take a look at a concrete example.
type A = { foo: number, bar: number };type B = { foo: number };const a1: A = { foo: 1, bar: 2 };const b1: B = { foo: 3 };const b2: B = a1;const a2: A = b1;// Property 'bar' is missing in type 'B' but required in type 'A'.
They key takeaway is that when we have an object of type
T
, all we know about that object is that it contains
at least
the properties in
T
.
We do
not
know whether we have
exactly
T
, which is why
Object.keys
is typed the way it is. Let’s take an example.
Unsafe usage of
Object.keys
Say that we’re creating an endpoint for a web service that creates a new user. We have an existing
User
interface that looks like so:
interface User {name: string;password: string;}
Before we save a user to the database, we want to ensure that the user object is valid.
name
must be non-empty.
password
must be at least 6 characters.
So we create a
validators
object that contains a validation function for each property in
User
:
const validators = {name: (name: string) => name.length < 1? "Name must not be empty": "",password: (password: string) => password.length < 6? "Password must be at least 6 characters": "",};
We then create a
validateUser
function to run a
User
object through these validators:
function validateUser(user: User) {// Pass user object through the validators}
Since we want to validate each property in
user
, we can iterate through the properties in
user
using
Object.keys
:
function validateUser(user: User) {let error = "";for (const key of Object.keys(user)) {const validate = validators[key];error ||= validate(user[key]);}return error;}
Note: There are type errors in this code block which I’m hiding for now. We’ll get to them later.
The problem with this approach is that the
user
object might contain properties not present in
validators
.
interface User {name: string;password: string;}function validateUser(user: User) {}const user = {name: 'Alex',password: '1234',};validateUser(user); // OK!
Even though
User
does not specify an
email
property, this is not a type error because structural typing allows extraneous properties to be provided.
At runtime, the
email
property will cause
validator
to be
undefined
and throw an error when invoked.
for (const key of Object.keys(user)) {const validate = validators[key];error ||= validate(user[key]);// TypeError: 'validate' is not a function.}
Luckily for us, TypeScript emitted type errors before this code had a chance to run.
for (const key of Object.keys(user)) {const validate = validators[key];// Expression of type 'string' can't be used to index type '{ name: ..., password: ... }'.error ||= validate(user[key]);// Expression of type 'string' can't be used to index type 'User'.}
We now have our answer for why
Object.keys
is typed the way it is. It forces us to acknowledge that objects may contain properties that the type system is not aware of.
With our newfound knowledge of structural typing and its pitfalls, let’s take a look at how we can effectively use structural typing to our benefit.
Making use of structural typing
Structural typing provides a lot of flexibility. It allows interfaces to declare exactly the properties which they need. I want to demonstrate this by walking through an example.
Imagine that we’ve written a function that parses a
KeyboardEvent
and returns the shortcut to trigger.
function getKeyboardShortcut(e: KeyboardEvent) {if (e.key === "s" && e.metaKey) {return "save";}if (e.key === "o" && e.metaKey) {return "open";}return null;}
To make sure that the code works as expected, we write some unit tests:
expect(getKeyboardShortcut({ key: "s", metaKey: true })).toEqual("save");expect(getKeyboardShortcut({ key: "o", metaKey: true })).toEqual("open");expect(getKeyboardShortcut({ key: "s", metaKey: false })).toEqual(null);
Looks good, but TypeScript complains:
getKeyboardShortcut({ key: "s", metaKey: true });// Type '{ key: string; metaKey: true; }' is missing the following properties from type 'KeyboardEvent': altKey, charCode, code, ctrlKey, and 37 more.
Ugh. Specifying all 37 additional properties would be super noisy, so that’s out of the question.
We could resolve this by casting the argument to
KeyboardEvent
:
getKeyboardShortcut({ key: "s", metaKey: true } as KeyboardEvent);
But that could mask other type errors that may be occuring.
Instead, we can update
getKeyboardShortcut
to only declare the properties it needs from the event.
interface KeyboardShortcutEvent {key: string;metaKey: boolean;}function getKeyboardShortcut(e: KeyboardShortcutEvent) {}
The test code now only needs to satisfy this more minimal interface, which makes it less noisy.