1

I have a rather complex interface that will have many well known instances. These instances should be referable by some kind of ID. Ideally I would like to define all of these well known instances in an object so that I can use the keyof operator to trivially have a type for the key.

interface Complex { 
  frobs: number[];
  type: 'A' | 'B';
} // More complicated in reality

const COMMON = {
  foo: { type: 'A', frobs: [] },           // this is fine
  bar: { type: 'B', frobs: [1], zorg: 1 }, // unwanted extra
  baz: { type: 'A', noFrobs: "uh-oh" },    // frobs missing
}

Type CommonId = keyof typeof COMMON; // Great, valid Ids are "foo" | "bar" | "baz"

But if I specify the object without any type (as above), Typescript of course doesn't enforce that all values in that object should be of the same type.

So I wanted to specify that all values must be of type Complex but I couldn't do so without specifying the key type. And now I have to mention every key twice: Once as part of the CommonId and once in the object:

interface Complex { 
  frobs: number[]; 
  type: 'A' | 'B';
} // More complicated in reality
type CommonId = "foo" | "bar";         // Much more common objects in reality

const COMMON: { 
  [commonId in CommonId]: Complex 
} = {
  foo: { type: 'A', frobs: [] },
  bar: { type: 'B', frobs: [1] }
}

Is there a way to get the best of both worlds? I would love to just extend the COMMON variable with a new key-value pair to automatically add that key to the CommonId type.

3 Answers 3

5

UPDATE

As of Typescript 4.9, this is now trivially done with the satisfies keyword:

const COMMON = {
  foo: { type: 'A', frobs: [] },          
  bar: { type: 'B', frobs: [1], zorg: 1 },
                            //  ~~~~~~~ <- ERROR
  baz: { type: 'A', noFrobs: "uh-oh" },
                //  ~~~~~~~~~~~~~~~~ <- ERROR
} satisfies Record<string, Complex>;

type CommonId = keyof typeof COMMON; // Good, it's still "foo" | "bar" | "baz"

ORIGINAL ANSWER

This is a further simplified solution (most credit goes to Aplet123 as it's highly inspired by his answer and especially his comments, with only an additional generic function I wrote). My use case was actually defining a config hence the makeConfig name.

export function makeConfig<C>() {
  return <K extends string>(cfg: Record<K, C>) => cfg;
}

If that's useful, you can export and re-use the config function for a common type:

interface Complex {
  frobs: number[];
  type: 'A' | 'B';
}

export const complexConfig = makeConfig<Complex>();

And then use it simply like this:

export const COMMON = complexConfig({
  foo: { type: 'A', frobs: [] },
  bar: { type: 'B', frobs: [1] }
});

(Note there's no need for the other variable/type declarations like CommonId, unless you want to export them)

Or if you want to use it just once for Complex type:

export const COMMON = makeConfig<Complex>()({
  foo: { type: 'A', frobs: [] },
  bar: { type: 'B', frobs: [1] }
});

EDIT: or slightly more concise:

// generic definition
type ValueTypeCheck<C> = <K extends string>(x: Record<K,C>) => Record<K,C>;

const valueType = <C,>() => (x => x) as ValueTypeCheck<C>;

// usage
const complex = valueType<Complex>();

export const COMMON = complex({...});

Overall, we really need a built-in mechanism in TS to enforce the type of values without using an indexed type, which then loses information about the key names...

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

Comments

3

You can first create a variable without the typing, use that to get the key type, then export the same value but with the types:

interface Complex { frobs: number[]; }

const _common = {
  foo: { frobs: [] },
  bar: { frobs: [1] }
};

type CommonId = keyof typeof _common;

export const COMMON: { 
  [commonId in CommonId]: Complex 
} = _common;

Playground link

6 Comments

Dang, I have chosen a too simple Complex type. Your example almost works, but as I have a union type as part of the complex objects I get errors like type string is not assignable to 'A' | 'B'
The Complex type shouldn't matter if you're using it as a value. (see here) I suspect that since your key types are restricted down now, when you try to index into the object with a string type then TS will complain due to not being one of the key types.
This is a link to the code on the Typescript Playground, it sadly doesn't compile: typescriptlang.org/play?#code/…
Ah, the error appears to be coming from the fact that your union relies on value types, which typescript does not automatically narrow down values to.
|
0
type ValueTypeCheck<T> = <K extends string>(x: Record<K,T>) => Record<K,T>
const valueType = <T>() => (x => x) as ValueTypeCheck<T>
const complex = valueType<YourObjectValueGoesHere>() // use type of object value

export const validationRules = complex({...}) // replace ... with your object

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.