Boolean Index Signature in TypeScript

2 hours ago 2

Miroslav Petrik

Press enter or click to view image in full size

Photo by Joshua Hoehne on Unsplash

Imagine that we are building a helper function cond to pick a truthy condition:

function cond(...cases) {
for (const condition of cases) {
if (condition.hasOwnProperty(true)) {
return condition[true]
}
}
}

let a = 11, b = 7;

const result = cond(
{[a < b]: "A is less than B"},
{[a > b]: "A is greater than B"},
{[a === b]: "A is equal to B"},
);
// result === "A is greater than B"

Even though this is a valid JavaScript, it won’t compile in TypeScript. Instead we get the following 3 errors:

function cond(...cases) {
for (const condition of cases) {
// Argument of type 'boolean' is not assignable to parameter of type 'PropertyKey'.(2345)
if (condition.hasOwnProperty(true)) {
// ~~~~
// Type 'true' cannot be used as an index type.(2538)
return condition[true]
// ~~~~
}
}
}

const result = cond(
// A computed property name must be of type 'string', 'number', 'symbol', or 'any'.(2464)
{[a < b]: "A is less than B"},
// ~~~~~
// ...
);

The error is basically the same — we are using boolean values as the object keys what TypeScript does not allow. In JavaScript this works, because objects used as keys, will be serialized to string:

// the boolean will get coerced to string.
let obj = {[true]: 1};

obj["true"]; // => 1

The coercion happens for numbers too:

let obj = {4: "four"};

obj[4]; // => "four"
obj[4] === obj["4"]; // => true

JavaScript uses string keys

In fact, there are no numeric keys in JavaScript. Not even in arrays:

Object.keys({4: "four"}); // ["4"]
Object.keys([1,2,3]); // => ["0", "1", "2"]

So why does TypeScript permit accessing object properties by number keys but not with boolean ? We can look at the PropertyKey definition:

// internal & global type
type PropertyKey = string | number | symbol;

// you can get PropertyKey also as
type PropKey = keyof any;

We see, that number is a constituent of the property keys. And it must be! Imagine, that we would get all the above errors from using boolean also for numbers. We would have to access array elements with strings instead:

// Hypotethical BadTypeScript without number indexes:
type PropertyKey = string | symbol;

let arr = [1,2,3];
arr[0]; // ERR! in BadTypeScript
arr["0"]; // OK

So given that using numeric indexes for arrays is idiomatic JavaScript, the TypeScript designers decided to support the number indexes, even though they are pure fiction.

Modeling Boolean index signature

So what if we want to support the “boolean index signature” in our user-land code? More specifically we would like the following (minus the error):

interface Case<T> {
// An index signature parameter type must be 'string', 'number', 'symbol',
// or a template literal type.(1268)
[index: boolean]: T
// ~~~~~~~
}

function cond<T>(...cases: Case<T>[]) {
// ...
}

But the error is an interesting one. On top of the PropertyKey union, it also mentions a template literal type. The template literal can be used to create similar keys, which share prefix:

interface WithData<T> {
[index: `data-${string}`]: T
}

But sadly it cannot expand literals:

interface Case<T> {
// An index signature parameter type cannot be a literal type or generic type.
// Consider using a mapped object type instead.(1337)
[index: `${boolean}`]: T
// ~~~~~~~~~~~~
}

So again as the error suggests, we can turn our interface into type, to compute property names from the boolean literal. Finally!

type Case<T> = {
[key in `${boolean}`]: string
}
// ^? type Case<T> = { false: T; true: T; }

Also we need to use the ? modifier, given that each case will have one or the other property:

type Case<T> = {
[key in `${boolean}`]?: string
}
// ^? type Case<T> = { false?: T | undefined; true?: T | undefined; }

Now, that we have our target shape for the individual cases we need to initialize objects of this shape. This takes us back to our original error:

// A computed property name must be of type 'string', 'number', 'symbol', or 'any'.(2464)
const testCase = {[a > b]: "A is greater than B"};
// ~~~~~~

We need to turn the boolean a > b into union of "false" | "true" . Magically this is possible with a const assertion:

const test = `${a < b}` as const;
// 🤯 ^? const test: "false" | "true"

But when we use that, we notice that the expanded key type is lost:

const testCase = {[`${a > b}` as const]: "A is greater than B"};
// ^? const testCase: { [x: string]: string; }

Why is that? TypeScript can infer objects with named properties when they are statically defined:

const key: "true" | "false" = "true"; // type annotation
// ^? const key = "true"
const foo = {[key]: "A is greater than B"}
// ^? typeof foo = { "true": string }
// ✅ The key is inlined

But we can’t statically tell if our expression will be true or false. It’s like using a type assertion instead of annotation, which clouds the const value:

const key2 = "false" as "true" | "false"; // type assertion
// ^? "true" | "false"

const bar = {[key2]: "A is greater than B"};
// ^? { [key2]: string; }
// 🚫 The key is not expanded

What we would need, is for the bar to distribute over the union and infer an union type:

const bar = {[key2]: "A is greater than B"};
// ^? { true: string; } | { false: string; }
// 🤔 This is our fantasy, TypeScript does not offer such inferrence.

We could annotate our case variables with the generic type. But that would make the solution cluttered. Moreover, the main purpose of TypeScript is to let it infer types for us! To get our fantasy inference working, we need to utilize a custom function:

function kv<K extends PropertyKey, V>(k: K, v: V): { [P in K]: { [Q in P]: V } }[K] {
return { [k]: v } as any
}

const bar = kv(key2, "A is greater than B");
// ✨ ^? { true: string; } | { false: string; }

The kv helper is a simply object creator, which permits one key and value pair. But the magic is the return type, which will be the union of possible record variants, based on the property key.

Note: the type transformation pattern is sometimes called IIMT

Finally, we can construct out case objects with the boolean keys:

const result = cond(
kv(`${a < b}`, "A is less than B"),
kv(`${a > b}`, "A is greater than B"),
kv(`${a === b}`, "A is equal to B"),
)

We see, that the function simply creates the plain objects for us, but it has improved inference. It still uses the PropertyKey so it is almost identical to the raw object creation.

See demo

Summary

In this example, we’ve explored the index signatures, and attempted to create a custom version of the boolean index signature. This was possible to do with computed named properties using template literals types and a custom, improved plain object inference with the immediately indexed mapped type — IIMT.

Key takeaways

  • JavaScript objects use only string keys in reality
  • The TypeScript number index signature is an illusion for the idiomatic use of number indices within arrays and similar structures
  • boolean literals cannot be used to as property keys in TypeScript, despite that being a valid JavaScript
  • the type can be used to expand boolean type literal into computed property names
  • TypeScript can’t infer union of records when the object property is a union
Read Entire Article