Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Merge Typescript Record or dictionary-like type with fixed key typings?

Tags:

typescript

Is there a way to extend the built-in Record (or a { [key:string]: string } interface) where you also define some fixed keys and their types?

Let's say we have this:

const otherValues = {
    some: 'some',
    other: 'other',
    values: 'values',
}

const composedDictionary = {
    id: 1,
    ...otherValues
}

I want to define an interface for composedDictionary where id is typed as number (and number only) and everything else as string.

I've tried this:

interface ExtendedRecord extends Record<string, string> {
    id: number;
}

and this:

interface MyDictionary {
    [key: string]: string;
    id: number;
}

Both fail with:

Property 'id' of type 'number' is not assignable to string index type 'string'

Any ideas?

like image 420
Meldon Avatar asked Aug 06 '19 08:08

Meldon


2 Answers

Ideally the index signature should reflect any possible indexing operation result. If you access composedDictionary with an uncheckable string key the result might be number if that string is actually 'id' (eg: composedDictionary['id' as string], the typings would say this is string but at runtime it turns out to be number). This is why the type system is fighting you on this, this is an inconsistent type.

You can define your index to be consistent will all properties:

interface MyDictionary {
    [key: string]: string | number;
    id: number;
}

There is a loophole to the checks typescript does for index and property consistency. That loop hole is intersection types:

type MyDictionary  = Record<string, string> & {
  id: number
}


const composedDictionary: MyDictionary = Object.assign({
    id: 1,
}, {
    ...otherValues
});

The compiler will still fight you on assignment, and the only way to create such an inconsistent object in the type system is by using Object.assign

like image 67
Titian Cernicova-Dragomir Avatar answered Oct 18 '22 20:10

Titian Cernicova-Dragomir


As the other answer said, TypeScript does not support a type in which some properties are exceptions to the index signature... and thus there is no way to represent your MyDictionary as a consistent concrete type. The inconsistent-intersection solution ({[k: string]: string]} & {id: number}) happens to work on property reads, but is difficult to use with property writes.


There was an old suggestion to allow "rest" index signatures where you can say that an index signature is supposed to represent all properties except for those specified.

There's also a more recent (but possibly shelved) pair of enhancements implementing negated types and arbitrary key types for index signatures, which would allow you to represent such exception/default index signature properties as something like { id: number; [k: string & not "id"]: string }. But that doesn't compile yet (TS3.5) and may never compile, so this is just a dream for now.


So you can't represent MyDictionary as a concrete type. You can, however, represent it as a generic constraint. Using it suddenly requires that all your previously concrete functions must become generic functions, and your previously concrete values must become outputs of generic functions. So it might be too much machinery than it's worth. Still, let's see how to do it:

type MyDictionary<T extends object> = { id: number } & {
  [K in keyof T]: K extends "id" ? number : string
};

In this case, MyDictionary<T> takes a candidate type T and transforms it into a version which matches your desired MyDictionary type. Then we use the following helper function to check if something matches:

const asMyDictionary = <T extends MyDictionary<T>>(dict: T) => dict;

Notice the self-referencing constraint T extends MyDictionary<T>. So here's your use case, and how it works:

const otherValues = {
  some: "some",
  other: "other",
  values: "values"
};

const composedDictionary = asMyDictionary({
  id: 1,
  ...otherValues
}); // okay

That compiles with no errors, because the parameter to asMyDictionary() is a valid MyDictionary<T>. Now let's see some failures:

const invalidDictionary = asMyDictionary({
    id: 1,
    oops: 2 // error! number is not a string
})

const invalidDictionary2 = asMyDictionary({
    some: "some" // error! property id is missing
})

const invalidDictionary3 = asMyDictionary({
    id: "oops", // error! string is not a number
    some: "some" 
})

The compiler catches each of those mistakes and tells you where the problem is.


So, this is the closest I can get to what you want in as of TS3.5. Okay, hope that helps; good luck!

Link to code

like image 34
jcalz Avatar answered Oct 18 '22 21:10

jcalz