How to define Typescript type as a dictionary of strings but with one numeric "id" property
Solution 1:
There is no specific type in TypeScript that corresponds to your desired structure. String index signatures must apply to every property, even the manually declared ones like id
. What you're looking for is something like a "rest index signature" or a "default property type", and there is an open suggestion in GitHub asking for this: microsoft/TypeScript#17867. A while ago there was some work done that would have enabled this, but it was shelved (see this comment for more info). So it's not clear when or if this will happen.
You could widen the type of the index signature property so it includes the hardcoded properties via a union, like
type WidenedT = {
id: number;
[key: string]: string | number
}
but then you'd have to test every dynamic property before you could treat it as a string
:
function processWidenedT(t: WidenedT) {
t.id.toFixed(); // okay
t.random.toUpperCase(); // error
if (typeof t.random === "string") t.random.toUpperCase(); // okay
}
The best way to proceed here would be if you could refactor your JavaScript so that it doesn't "mix" the string
-valued bag of properties with a number
-valued id
. For example:
type RefactoredT = {
id: number;
props: { [k: string]: string };
}
Here id
and props
are completely separate and you don't have to do any complicated type logic to figure out whether your properties are number
or string
valued. But this would require a bunch of changes to your existing JavaScript and might not be feasible.
From here on out I'll assume you can't refactor your JavaScript. But notice how clean the above is compared to the messy stuff that's coming up:
One common workaround to the lack of rest index signatures is to use an intersection type to get around the constraint that index signatures must apply to every property:
type IntersectionT = {
id: number;
} & { [k: string]: string };
It sort of kind of works; when given a value of type IntersectionT
, the compiler sees the id
property as a number
and any other property as a string
:
function processT(t: IntersectionT) {
t.id.toFixed(); // okay
t.random.toUpperCase(); // okay
t.id = 1; // okay
t.random = "hello"; // okay
}
But it's not really type safe, since you are technically claiming that id
is both a number
(according to the first intersection member) and a string
(according to the second intersection member). And so you unfortunately can't assign an object literal to that type without the compiler complaining:
t = { id: 1, random: "hello" }; // error!
// Property 'id' is incompatible with index signature.
You have to work around that further by doing something like Object.assign()
:
const propBag: { [k: string]: string } = { random: "" };
t = Object.assign({ id: 1 }, propBag);
But this is annoying, since most users will never think to synthesize an object in such a roundabout way.
A different approach is to use a generic type to represent your type instead of a specific type. Think of writing a type checker that takes as input a candidate type, and returns something compatible if and only if that candidate type matches your desired structure:
type VerifyT<T> = { id: number } & { [K in keyof T]: K extends "id" ? unknown : string };
This will require a generic helper function so you can infer the generic T
type, like this:
const asT = <T extends VerifyT<T>>(t: T) => t;
Now the compiler will allow you to use object literals and it will check them the way you expect:
asT({ id: 1, random: "hello" }); // okay
asT({ id: "hello" }); // error! string is not number
asT({ id: 1, random: 2 }); // error! number is not string
asT({ id: 1, random: "", thing: "", thang: "" }); // okay
It's a little harder to read a value of this type with unknown keys, though. The id
property is fine, but other properties will not be known to exist, and you'll get an error:
function processT2<T extends VerifyT<T>>(t: T) {
t.id.toFixed(); // okay
t.random.toUpperCase(); // error! random not known to be a property
}
Finally, you can use a hybrid approach that combines the best aspects of the intersection and generic types. Use the generic type to create values, and the intersection type to read them:
function processT3<T extends VerifyT<T>>(t: T): void;
function processT3(t: IntersectionT): void {
t.id.toFixed();
if ("random" in t)
t.random.toUpperCase(); // okay
}
processT3({ id: 1, random: "hello" });
The above is an overloaded function, where callers see the generic type, but the implementation sees the intersection type.
Playground link to code