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',
}