Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

typescript

Existing JavaScript code has "records" where the id is numeric and the other attributes string. Trying to define this type:

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

gives error 2411 id type number not assignable to string

like image 962
Fred Avatar asked Apr 25 '20 19:04

Fred


People also ask

What is mapped type in TypeScript?

A mapped type is a generic type which uses a union of PropertyKey s (frequently created via a keyof ) to iterate through keys to create a type: type OptionsFlags < Type > = { [ Property in keyof Type ]: boolean; };

Is not assignable to string index type number?

The "Type 'string' is not assignable to type" TypeScript error occurs when we try to assign a value of type string to something that expects a different type, e.g. a more specific string literal type or an enum. To solve the error use a const or a type assertion.


1 Answers

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

like image 149
jcalz Avatar answered Oct 19 '22 03:10

jcalz