Typescript: declare that ALL properties on an object must be of the same type
Approach Generics with a no-op function can be extended to have a generic function accepting a type of required values, which itself returns no-op function. This way it won't be required to create new function for each type
export const typedRecord = <TValue>() => <T extends Record<PropertyKey, TValue>>(v: T): T => v;
To understand what happens here below is alternative declaration of typedRecord
function from above. typedRecord
function accepts type parameter TValue
for the property type of the record and returns another function which will be used to validate structure of the type T
passed to it (TS compiler will infer T
from invocation)
export function typedRecord<TValue>() {
return function identityFunction<T extends Record<PropertyKey, TValue>>(v: T): T {
return v;
};
}
This covers all requirements
const allTheThings = typedRecord<Thing>()({
first: { name: "first thing name" },
second: { name: "second thing name" },
third: { name: "third thing name" },
fourth: { oops: "lol!" }, // error here
});
allTheThings.first;
allTheThings.nonexistent; // error here
Solution 1: Indexable type
interface Thing {
name: string
}
interface ThingMap {
[thingName: string]: Thing
}
const allTheThings: ThingMap = {
first: { name: "first thing name" },
second: { name: "second thing name" },
third: { name: "third thing name" },
}
The downside here is that you'd be able to access any property off of allTheThings
without any error:
allTheThings.nonexistent // type is Thing
This can be made safer by defining ThingMap
as [thingName: string]: Thing | void
, but that would require null checks all over the place, even if you were accessing a property you know is there.
Solution 2: Generics with a no-op function
const createThings = <M extends ThingMap>(things: M) => things
const allTheThings = createThings({
first: { name: "first thing name" },
second: { name: "second thing name" },
third: { name: "third thing name" },
fourth: { oops: 'lol!' }, // error here
})
allTheThings.first
allTheThings.nonexistent // comment out "fourth" above, error here
The createThings
function has a generic M
, and M
can be anything, as long as all of the values are Thing
, then it returns M
. When you pass in an object, it'll validate the object against the type after the extends
, while returning the same shape of what you passed in.
This is the "smartest" solution, but uses a somewhat clever-looking hack to actually get it working. Regardless, until TS adds a better pattern to support cases like this, this would be my preferred route.
Use generic and specify which properties type do you want.
type SamePropTypeOnly<T> = {
[P: string]: T;
}
interface MyInterface {
name: string;
}
const newObj: SamePropTypeOnly<MyInterface> = {
first: { name: 'first thing name' },
second: { name: 'second thing name' },
third: { name: 'third thing name' },
// forth: 'Blah' // Type 'string' is not assignable to type `MyInterface`
}
newObj.not_there; // undefined - no error
Note: if the list of property names has to be limited, keys have to be specified explicitly:
interface MyInterface {
name: string;
}
type OptionKeys = 'first' | 'second' | 'third';
const newObj: Record<OptionKeys, MyInterface> = {
first: { name: 'first thing name' },
second: { name: 'second thing name' },
third: { name: 'third thing name' },
// forth: 'Blah' // error
}
newObj.not_there // Property 'not_there' does not exist on type...
Some alternatives for single level (flat) objects:
Alternative 1 (indexable type):
const exampleObj: { [k: string]: string } = {
first: 'premier',
second: 'deuxieme',
third: 'troisieme',
}
Alternative 2 (Record):
const exampleObj: Record<string, string> = {
first: 'premier',
second: 'deuxieme',
third: 'troisieme',
}
Alternative 3 (Record / Union):
const exampleObj: Record<'first' | 'second' | 'third', string> = {
first: 'premier',
second: 'deuxieme',
third: 'troisieme',
}