68

I want to define the type of an object but let typescript infer the keys and don't have as much overhead to make and maintain a UnionType of all keys.

Typing an object will allow all strings as keys:

const elementsTyped: { 
    [key: string]: { nodes: number, symmetric?: boolean }
} = {
    square: { nodes: 4, symmetric: true },
    triangle: { nodes: 3 }
}

function isSymmetric(elementType: keyof typeof elementsTyped): boolean {
    return elementsTyped[elementType].symmetric;
}
isSymmetric('asdf'); // works but shouldn't

Inferring the whole object will show an error and allows all kind of values:

const elementsInferred = {
    square: { nodes: 4, symmetric: true },
    triangle: { nodes: 3 },
    line: { nodes: 2, notSymmetric: false /* don't want that to be possible */ }
}

function isSymmetric(elementType: keyof typeof elementsInferred): boolean {
    return elementsInferred[elementType].symmetric; 
    // Property 'symmetric' does not exist on type '{ nodes: number; }'.
}

The closest I got was this, but it don't want to maintain the set of keys like that:

type ElementTypes = 'square' | 'triangle'; // don't want to maintain that :(
const elementsTyped: { 
    [key in ElementTypes]: { nodes: number, symmetric?: boolean }
} = {
    square: { nodes: 4, symmetric: true },
    triangle: { nodes: 3 },
    lines: { nodes: 2, notSymmetric: false } // 'lines' does not exist in type ...
    // if I add lines to the ElementTypes as expected => 'notSymmetric' does not exist in type { nodes: number, symmetric?: boolean }
}

function isSymmetric(elementType: keyof typeof elementsTyped): boolean {
    return elementsTyped[elementType].symmetric;
}
isSymmetric('asdf'); // Error: Argument of type '"asdf"' is not assignable to parameter of type '"square" | "triangle"'.

Is there a better way to define the object without maintaining the set of keys?

1

3 Answers 3

51

TypeScript >=4.9.0

TypeScript 4.9.0 adds the satisfies keyword which can be used to constrain the values of an object while inferring the keys.

const foo = {
  bar: someTypedObject,
} satisfies Record<string, SomeType>

TypeScript <4.9.0

So you want something that infers keys but restricts the value types and uses excess property checking to disallow extra properties. I think the easiest way to get that behavior is to introduce a helper function:

// Let's give a name to this type
interface ElementType {
  nodes: number,
  symmetric?: boolean
}

// helper function which infers keys and restricts values to ElementType
const asElementTypes = <T>(et: { [K in keyof T]: ElementType }) => et;

This helper function infers the type T from the mapped type of et. Now you can use it like this:

const elementsTyped = asElementTypes({
  square: { nodes: 4, symmetric: true },
  triangle: { nodes: 3 },
  line: { nodes: 2, notSymmetric: false /* error where you want it */} 
});

The type of the resulting elementsTyped will (once you fix the error) have inferred keys square, triangle, and line, with values ElementType.

Hope that works for you. Good luck!

Sign up to request clarification or add additional context in comments.

8 Comments

ok I feared that it might need an helper. would be amazing if typescript could do that without it. also I can use that only for this usecase. is there a way to make the ElementType generic too? I wasn't able to without breaking the key inference or excess property checking (thanks for the keyword ;) )
well would be nice if I could use that there was a generic asTypedObject<MyValueType>(...) function. here: asElementTypes= asTypedObject<ElementType>.
In the absence of partial type parameter inference you can use currying to achieve this: const asTypedObject = <E>() => <T>(et: { [K in keyof T]: E }) => et; const asElementType = asTypedObject<ElementType>();
It might be possible to get closer to what you’re looking for but the comment section of an already-answered question probably isn’t a great forum for it. If you’re really interested in getting an answer to this it might be worth making a new question so you get more eyes on it... after you search, of course, since “how to require a type parameter” might be answered elsewhere.
@Jack: function asElementTypes<T>() { return function <Obj>(obj: { [K in keyof Obj]: T }) { return obj; }; }
|
15

TypeScript >=4.9.0

TypeScript 4.9.0 adds the satisfies keyword which can be used to constrain the values of an object while inferring the keys.

type ElementValue = {
  nodes: number;
  symmetric?: boolean;
};

const elements = {
  square: { nodes: 4, symmetric: true },
  triangle: { nodes: 3 },
} satisfies Record<string, ElementValue>;

type Elements = typeof elements;
type ElementType = keyof Elements;

function isSymmetric(elementType: ElementType): boolean {
  const element = elements[elementType];
  return 'symmetric' in element && element.symmetric;
}

isSymmetric('asdf'); // doesn't work

TypeScript <4.9.0

An intermediate function can be used to constrain the values of an object while inferring the keys.

type ElementValue = {
  nodes: number;
  symmetric?: boolean;
};

function typedElements<T extends Record<string, ElementValue>>(o: T) {
  return o;
}

const elements = typedElements({
  square: { nodes: 4, symmetric: true },
  triangle: { nodes: 3 },
});

type Elements = typeof elements;
type ElementType = keyof Elements;

function isSymmetric(elementType: ElementType): boolean {
  const element = elements[elementType];
  return 'symmetric' in element && element.symmetric;
}

isSymmetric('asdf'); // doesn't work

1 Comment

Use satisfies should be the new accepted answer. Easiest and needs no helpers.
0

Another, simpler solution involves the use of enum instead of complex TS type inheritance:

type SiteRoute = {
  title: string
  path: string
  isAdmin?: boolean
  disabled?: boolean
}

enum Routes {
    home = "home",
    events = "events",
    contacts = "contacts",
}

const ROUTES_DETAILS: Record<Routes, SiteRoute> = {
    [Routes.home]: {
        title: "Home",
        path: "/",
    },
    [Routes.events]: {
        title: "Events",
        path: "/events",
        disabled: true,

    },
    [Routes.contacts]: {
        title: "Contacts",
        path: "/contacts",
        isAdmin: true,
        disabled: true,
    },
}

ROUTES_DETAILS.home  // ok with autocomplete
ROUTES_DETAILS.asa   // Error: Property 'asa' does not exist on type

This solution is more readable and uses basic types and enum. The only drawback is that enum should be paired with a string value in order to be human-readable in autocompletion.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.