2

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 
})
2
  • 1
    You can't annotate example as Foo and then ask the compiler to remember the string literal values from example.one[someNumber].key; that's just string in the definition of Foo. If you want the compiler to keep track of it, you need example to be of a type which represents those string literals, and then you can use Record<typeof example["one"][number]["key"], string> like this. If this works for you I'll write up an answer. If not, please elaborate on what's missing. Commented Aug 19, 2021 at 13:56
  • Perfect!! One thing I didn't understand about adding asFoo function. If you write this answer explaining the same would be helpful 😊 Commented Aug 19, 2021 at 14:08

1 Answer 1

1

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

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.