Typescript: declare that ALL properties on an object must be of the same type

19,621

Solution 1

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.

Solution 2

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

Solution 3

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...

Solution 4

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
Share:
19,621
LaVache
Author by

LaVache

Updated on June 15, 2022

Comments

  • LaVache
    LaVache about 2 years

    In Typescript you can declare that all elements in an array are of the same type like this:

    const theArray: MyInterface[]
    

    Is there anything similar you can do that declares that ALL of an object's property values must be of the same type? (without specifying every property name)

    For example, I'm currently doing this:

    interface MyInterface {
        name:string;
    }
    
    const allTheThingsCurrently = {
        first: <MyInterface>{name: 'first thing name' },
        second: <MyInterface>{name: 'second thing name' },
        third: <MyInterface>{name: 'third thing name' },
        //...
    };
    

    ...note how I have to specify <MyInterface> for every single property. Is there any kind of shortcut for this? i.e. I'm imagining something like this...

    const allTheThingsWanted:MyInterface{} = {
        first: {name: 'first thing name' },
        second: {name: 'second thing name' },
        third: {name: 'third thing name' },
        //...
    };
    

    MyInterface{} is the part that's invalid code and I'm looking for a way to do with less redundancy, and optionally the extra strictness that prevents any other properties being adding to the object of a differing type.

  • Titian Cernicova-Dragomir
    Titian Cernicova-Dragomir almost 6 years
    Not sure I agree with the description of the generic option as a 'hack' or 'abuse', it is a good option :)
  • kingdaro
    kingdaro almost 6 years
    Sure, it works, I just wished there were a more transparent option than a useless function call, but oh well
  • ciekawy
    ciekawy almost 5 years
    love solution 2! wonder if it would be possible to make the Thing being generic to make createThings reusable as createThings<ValueType>({}). Then the last thing would be to have some good common (guava like?) place to put it there
  • Ruan Mendes
    Ruan Mendes almost 3 years
    When I use newObj, TS doesn't know what properties exist. It doesn't complain if I use newObj.not_there
  • Ruan Mendes
    Ruan Mendes almost 3 years
    @ciekawy TypeScript does not support generics of generics, or higher kinded types. I needed it for a different use case.
  • am0wa
    am0wa almost 3 years
    @JuanMendes which TS version do you use?
  • Ruan Mendes
    Ruan Mendes almost 3 years
  • am0wa
    am0wa almost 3 years
    Note: goal was to check the type of the each property not to limit its name: newObj.foo.not_there // Property 'not_there' does not exist on type 'MyInterface'
  • Ruan Mendes
    Ruan Mendes almost 3 years
    Yes, that would do the trick but specifying the properties as strings seems smelly
  • am0wa
    am0wa almost 3 years
    Well, that's same that Record type requires (updated example)
  • redOctober13
    redOctober13 over 2 years
    I wish I understood that export line. There are way too many Ts and Vs.
  • Anton
    Anton over 2 years
    @redOctober13 I've edited my answer. Hope it's easier to understand it now.