65

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?

Jack
  • 753
  • 1
  • 5
  • 6
  • Possible duplicate of [Is it possible to infer the keys of a Record in TypeScript?](https://stackoverflow.com/questions/49538199/is-it-possible-to-infer-the-keys-of-a-record-in-typescript) – Louis Mar 04 '19 at 19:39

2 Answers2

46

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!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 2
    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 ;) ) – Jack Feb 11 '19 at 15:12
  • Not sure what you mean by "make the `ElementType` generic" without a concrete example. And if the new question is significantly different from the question as asked you might want to post a new one. – jcalz Feb 11 '19 at 15:23
  • 1
    well would be nice if I could use that there was a generic `asTypedObject(...)` function. here: `asElementTypes= asTypedObject`. – Jack Feb 13 '19 at 17:30
  • 7
    In the absence of [partial type parameter inference](https://github.com/Microsoft/TypeScript/pull/26349) you can use [currying](https://en.wikipedia.org/wiki/Currying) to achieve this: `const asTypedObject = () => (et: { [K in keyof T]: E }) => et; const asElementType = asTypedObject();` – jcalz Feb 13 '19 at 20:14
  • thanks although unfortunately that function works without the addional type and then obviously doesn't have the excess property checking. `const asElementType = asTypedObject(); // forgot `. well I don't think I need that `asTypedObject` but was interested in if thats possible. – Jack Feb 14 '19 at 10:50
  • 1
    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. – jcalz Feb 14 '19 at 11:31
  • 1
    @Jack: function asElementTypes() { return function (obj: { [K in keyof Obj]: T }) { return obj; }; } – tokland Feb 01 '21 at 09:26
  • @tokland anyway to use that function without the double invocation asProvidedType()({a:"a"}) – chrismarx May 12 '21 at 18:41
8

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
marcusstenbeck
  • 735
  • 6
  • 15