Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
1.8k views
in Technique[技术] by (71.8m points)

typescript - Create a Record type with fixed length keys from string array of keys in another interface

This is the minimal code example. I have an interface named Foo which has property one which accepts an array of the object type.

interface Foo {
    one: {
        key: string;
       // more properties
    }[]
}

Here the type of key is a string.

I want to create a Record type where keys would be of string literal union type which we would take from the array of key of one property in Foo type and values can be any string type.

I am getting issues in creating that type.

Contrived Example:

const example: Foo = { 
    one: [{ key: "mykey1" }, {key: "mykey2"}, {key: "mykey3"}]
}

const A = {} as Record<string, string>; // as of now I did it like this
// I need to strongly type this constant.

example.one.map(el => {
  A[el.key] = "anything" // contrived example 
})
See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Answer

0 votes
by (71.8m points)

Annotating a variable as Foo throws away the key information you need

The main obstacle here is that when you annotate a variable with a (non-union) type, like const example: Foo = ..., the compiler will treat that variable as being of that type regardless of what value you initialize it with. And so any more specific information about the initialized value, such as the string literal type "mykey1", will be thrown away. The definition of Foo has just string as the key property of the elements of the one array, and so string is what you'll get if you try to inspect the variable's type:

const example: Foo = {
    one: [{ key: "mykey1" }, { key: "mykey2" }, { key: "mykey3" }]
};
// const example: Foo
type KeyType = typeof example["one"][number]["key"]
// type KeyType = string, oops

So you really don't want to annotate example (unless you want to manually write out the full type with all the information needed). Instead you should try to get the compiler to infer the type of example in such a way that it has the information you care about, while still being seen as assignable to Foo.


Simply omitting the annotation also throws this key information away

If we simply leave off the annotation, that's also not sufficient:

const example = {
    one: [{ key: "mykey1" }, { key: "mykey2" }, { key: "mykey3" }]
};
// const example: {  one: {  key: string; }[]; }
type KeyType = typeof example["one"][number]["key"]
// type KeyType = string, oops

Again, it's just string. That's because the default heuristics for inferring variable types tends to widen any string literal properties to just string. Properties are sometimes reassigned, and the compiler has no idea that you don't intend to write example.one[0].key = "somethingElse". And so string is the type it infers instead of "mykey1". We need to do something else.


A const assertion will preserve the key information, but it's not compatible with Foo anymore

If you want to tell the compiler that you are not going to modify the contents of example and that it should try to infer the most specific type possible, you can use a const assertion on the initializer:

const example = {
    one: [{ key: "mykey1" }, { key: "mykey2" }, { key: "mykey3" }]
} as const;
/* const example: { readonly one: readonly [
    { readonly key: "mykey1"; }, 
    { readonly key: "mykey2"; }, 
    { readonly key: "mykey3"; }
    ];
*/

type KeyType = typeof example["one"][number]["key"]
// type KeyType =  "mykey1" | "mykey2" | "mykey3", hooray!

Now example is inferred to be an object with a readonly property containing a readonly array of objects with readonly properties whose values are the string literals we care about. And so KeyType is exactly the union of string literals we want.

That's great, and we'd be done here, except for one wrinkle. The inferred type of example is not assignable to your Foo as defined. It turns out that readonly arrays are not assignable to mutable arrays (it is the other way around), and so this happens:

function acceptFoo(foo: Foo) { }
acceptFoo(example); // error! one is readonly
// -----> ~~~~~~~

Maybe you should redefine Foo to be compatible with a const assertion

Are you ever going to add or remove elements from a Foo's one property? If not, then the easiest solution here is to just redefine Foo:

interface Foo {
    one: readonly { // <-- change to readonly array type
        key: string;
        // more properties
    }[]
}

acceptFoo(example); // okay now

If you don't want to make that change, then you need some other solution. And even if you do, leaving off the annotation and using as const has the side effect that example really might not be a valid Foo, and you wouldn't catch it until you tried to use it as a Foo later:

const example = {
    one: [{ key: "mykey1" }, { key: "mykey2" }, { kee: "mykey3" }]
} as const; // no error here

/* const example: { readonly one: readonly [
    { readonly key: "mykey1"; }, 
    { readonly key: "mykey2"; }, 
    { readonly kee: "mykey3"; }
    ];
*/

acceptFoo(example); // error here

The third element of one has a kee property instead of a key property. That's a mistake, but there's no error at the const example = ... line because nothing says it has to be a Foo. You get an error later when you treat it like a Foo.

This might be acceptable to you, or maybe not. If it is, then we can stop here. If not, read on:


Or you can make a generic helper function to infer a Foo-compatible type that also preserves key information

Another idea instead of using as const is to make a generic helper function that guides the inference process. At runtime it would just return its input, so it looks like a no-op. Here's one way to do it:

interface FooWithKeys<K extends string> extends Foo {
    one: {
        key: K;
        // more properties
    }[]
}
const asFoo = <K extends string>(fwk: FooWithKeys<K>) => fwk;

The FooWithKeys<K> type is an extension of Foo where the keys in one are known to be K, which is constrained to be a subtype of string. A type FooWithKeys<"a" | "b"> is assignable to Foo, but the compiler knows that the keys are a or b and not just string.

The asFoo() helper function will look at its input and infer a type for K that is consistent with it. Since the type parameter K is constrained to string, the compiler will try to infer string literal types for it if possible.

Let's see it in action:

const example = asFoo({
    one: [{ key: "mykey1" }, { key: "mykey2" }, { key: "mykey3" }]
})
// const example: FooWithKeys<"mykey1" | "mykey2" | "mykey3">

Looks good. Now we can get the key type as before:

type KeyType = typeof example["one"][number]["key"]
// type KeyType =  "mykey1" | "mykey2" | "mykey3", hooray!

and make your A dictionary:

const A = {} as Record<KeyType, string>;
example.one.map(el => {
    A[el.key] = "anything"
})

And example is still seen as a Foo:

acceptFoo(example); // okay

And if we made any mistake with example, we'd get an error right there:

const example = asFoo({
    one: [{ key: "mykey1" }, { key: "mykey2" }, { kee: "mykey3" }] // error!
    // -----------------------------------------> ~~~~~~~~~~~~~
    // Object literal may only specify known properties, and 'kee' does not exist in type
})

Playground link to code


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

2.1m questions

2.1m answers

60 comments

56.5k users

...