Question

How to define Typescript type as a dictionary of strings but with one numeric "id" property

My existing JavaScript code has "records" where the id is numeric and the other attributes are strings. I'm trying to define this type:

type T = {
    id: number;
    [key: string]: string
}

but I get error 2411: Property 'id' of type 'number' is not assignable to 'string' index type 'string'.

 46  15010  46
1 Jan 1970

Solution

 64

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

2020-04-26

Solution

 1

You are getting this error since you have declared it as Indexable Type (ref: https://www.typescriptlang.org/docs/handbook/interfaces.html#indexable-types) with string being the key type, so id being a number fails to conform to that declaration.

It is difficult to guess your intention here, but may be you wanted something like this:

class t {
  id: number;
  values = new Map<string, string>();
}
2020-04-25