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