Reputation: 1782
What I'd like to do is to create an object which is a Record of a certain interface and have TypeScript be able to infer the keys based on what's in my object. I've tried a few things, none of which do exactly what I'm looking for.
interface Person {
firstName:string
}
const peopleObj = {
Jason: {
firstName: "Jason"
},
Tim: {
firstName: "Tim"
}
} as const;
console.log(peopleObj);
Here, if you look at peopleObj, TypeScript knows the exact keys because of the as const
. The problem here is I'm not enforcing each object to be a Person
. So I tried this next:
const peopleObj: Record<string, Person> = {
Jason: {
firstName: "Jason"
},
Tim: {
firstName: "Tim"
}
} as const;
Here, each object has to be a Person
because of the Record
defined, but TypeScript loses its ability to know all of the keys because now they are just string
instead of the constants 'Jason' | 'Tim'
, and this is the crux of the issue. I know I could explicitly use 'Jason' | 'Tim'
in place of my string
type, but this is a fairly large object in real life and updating that type explicitly every time I add to it or remove from it is getting to be tedious.
Is there a way to have the best of both worlds, where I can have TypeScript infer the keys in the object just based solely on what's in the object? I have found a way, although it's not super clean and I feel like there's likely a better way:
interface Person {
firstName:string
}
type PeopleType<T extends string> = Record<T, Person>;
const peopleObj: Record<string, Person> = {
Jason: {
firstName: "Jason"
},
Tim: {
firstName: "Tim"
}
} as const;
const People:Record<keyof typeof peopleObj, Person> = peopleObj;
console.log(People.Jason);
Upvotes: 4
Views: 2406
Reputation: 4010
Your third method doesn't actually work I believe - as you can access People.foo without error. This is because when you construct peopleObj as Record<string, Person>
, its type is now that. You then do keyof Record<string, Person>
, which evaluates to string.
The only way I'm aware of to achieve this is via using a function with generics. This allows you to apply a constraint on the input parameter, and then return the original type.
const createPeople = <T extends Record<string, Person>>(people: T) => people;
const myPeople = createPeople({
Jason: {
firstName: "Jason"
},
Tim: {
firstName: "Tim"
}
});
console.log(myPeople.Jason);
console.log(myPeople.foo); // error
You have a catch 22 situation otherwise - i.e - I want to enforce that my keys are of type Person, but I don't know what my keys are.
One other way that you may prefer - which is basically the same thing but without the function:
interface Person {
firstName:string
}
// Force evaluation of type to expanded form
type EvaluateType<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
type PeopleType<T extends Record<string, Person>> = EvaluateType<{[key in keyof T]: Person }>;
const peopleLookup = {
Jason: {
firstName: "Jason"
},
Tim: {
firstName: "Tim"
}
};
const people: PeopleType<typeof peopleLookup> = peopleLookup;
console.log(people.Jason);
console.log(people.foo);
Upvotes: 3
Reputation: 42188
I'm expanding on @mbdavis's answer because there seems to be an additional constraint, which is that the key for each Person
in the record is that Person
's firstName
property. This allows us to use mapped types.
A PeopleRecord
should be an object such that for every key name
, the value is either a Person
object with {firstName: name}
or undefined
;
type PeopleRecord = {
[K in string]?: Person & {firstName: K}
}
Unfortunately it doesn't work the way we want it to if we just apply this type to peopleObj
, since it doesn't see the names as anything more specific than string
. Maybe another user can figure out how to make it work, but I can't.
Like @mbdavis, I need to reassign the object to enforce that it matches a more specific constraint.
I use the mapped type ValidateRecord<T>
which takes a type T
and forces that every key on that type is a Person
with that first name.
type ValidateRecord<T> = {
[P in keyof T]: Person & {firstName: P}
}
Now we can reassign your peopleObj
, which should only work if the object is valid, and will throw errors otherwise.
const validatedObj: ValidateRecord<typeof peopleObj> = peopleObj;
But it's a lot cleaner to do this assertion through a function. Note that the function itself merely returns the input. You could do any other validation checking on the JS side of things here.
const asValid = <T extends {}>( record: T & ValidateRecord<T> ): ValidateRecord<T> => record;
const goodObj = {
Jason: {
firstName: "Jason"
},
Tim: {
firstName: "Tim"
}
} as const;
// should be ok
const myRecordGood = asValid(goodObj);
// should be a person with {firstName: "Tim"}
const A = myRecordGood["Tim"]
// should be a problem
const B = myRecordGood["Missing"]
const badObj = {
Jason: {
firstName: "Bob"
},
Tim: {
firstName: "John"
}
} as const;
// should be a problem
const myRecordBad = asValid(badObj);
Upvotes: 0