Lukas
Lukas

Reputation: 10360

TypeScript type with specific set of keys and values

Updated Version

Full Playground here

https://www.typescriptlang.org/play?#code/C4TwDgpgB...

I am trying to create an object type that only allows specific keys, but for each key you must extend a specific generic type as value. I.e.

type AllowedKeys = "a" | "b" | "c";

type MyGenericType<T extends AllowedKeys> = {
  type: T;
}

type MyObjectTypeGuard = {
  [T in AllowedKeys]: MyGenericType<T>
}

// This is what I am aiming at, I tried to make sure that I now have an interface the should prevent me from using the only allowed keys with the allowed values
interface MyObject extends MyObjectTypeGuard {
  a: { type: "a" } & { foo: "foo" };
  b: { type: "b" } & { bar: "bar" }; 
  c: { type: "c" } & { baz: "baz" };
}

Now that prevents me from using keys with the wrong values

// I cannot accidentially use the wrong base types
interface MyObject2 extends MyObjectTypeGuard {
  a: { type: "b" } & { foo: "foo" };
  b: { type: "b" } & { bar: "bar" }; 
  c: { type: "c" } & { baz: "baz" };
}

// Or use completely wrong types even
interface MyObject3 extends MyObjectTypeGuard {
  a: { foo: "foo" };
  b: { type: "b" } & { bar: "bar" }; 
  c: { type: "c" } & { baz: "baz" };
}

But I can still do other things that I want myself to prevent from

// But I can still miss a key
interface MyObject4 extends MyObjectTypeGuard {
  a: { type: "a" } & { foo: "foo" };
  b: { type: "b" } & { bar: "bar" }; 
}

// And I can still add arbitrary stuff
interface MyObject5 extends MyObjectTypeGuard {
  a: { type: "a" } & { foo: "foo" };
  b: { type: "b" } & { bar: "bar" }; 
  c: { type: "c" } & { baz: "baz" };
  fooBar: "baz";
}

I am looking for a way to define an object type / interface that forces me to use all keys (and only those) and has me extend a specific generic type for each value

Old version

We have a base model BodyNode which can be extended into variants:


type BodyNode<T extends NodeType> = {   readonly type: T; };

type NodeWithText<T extends NodeType> = BodyNode<T> & WithText; type
NodeWithChildren<T extends NodeType> = BodyNode<T> & WithChildren;
type NodeWithReference<T extends NodeType> = BodyNode<T> &
WithReference;

interface WithText {   readonly text: string; }

interface WithChildren {   readonly children: Node[]; }

interface WithReference {   readonly href: string; } ```

to be able to iterate over the node types, we created an interface
that maps `NodeType`s to variants

types:

```typescript type BodyNodes = {   [T in NodeType]: BodyNode<T>; };

interface Nodes extends BodyNodes {   headline: NodeWithText;  
paragraph: NodeWithChildren;   anchor: NodeWithReference; }

type Node = Nodes[keyof Nodes]; ```

implementation:

```tsx type Components = {   [K in BodyNode]: React.FC<{ node:
Nodes[K] }>; };

const components: Components = {   headline: Headline,   paragraph:
Paragraph,   anchor: Anchor, };

const Body = (props: { nodes: Node[] }) => (   <div>
    {props.nodes.map(node => {
      const Component = components[node.type];
      return <Component node={node} />;
    })}   </div> ); ```

we are using the node types as keys for the `Nodes` as well as the
`Components` and with this we can map the Nodes with the right types
to the right components in type safe way.

But this implementation has a big flaw:

```typescript type BodyNodes = {   [T in NodeType]: BodyNode<T>; };

interface Nodes extends BodyNodes {   headline: BodyNode<'headline'>; 
paragraph: BodyNode<'headline'>;   //                     ^^^^^^^^
this will cause an error, which is what we want   anchor:
BodyNode<'anchor'>;   yadda: 'foobar';   //^^^^^^^^^^^^^^^^ this is
still possible because we can extend BodyNodes in   //                
any way and that's not cool } ```

We'd love to find a way to write a type that allows only

- specific keys as above
- for specific keys only specific values as above
- requires each key to be there
- BUT forbids extending, ie. have an exact implementaition of that type and nothing else

Upvotes: 3

Views: 2862

Answers (1)

Rich N
Rich N

Reputation: 9475

I don't think you can do this with interfaces: the 'extends' always means you can extend. However how about what's below? It's a little clunky since we're defining our new type inside <>, but I think it does what you want.

type AllowedKeys = "a" | "b" | "c";

type MyGenericType<T extends AllowedKeys> = {
    type: T;
}

type MyObjectTypeGuard = { [T in AllowedKeys]: MyGenericType<T> };

type VerifyType<T extends MyObjectTypeGuard &
    { [U in Exclude<keyof T, AllowedKeys>]: never }> = T;

// Incorrect type letters or missing 'type', additional properties,
// and missing properties lead to errors
type VerifiedType = VerifyType<{
    a: { type: "a" } & { foo: "foo" };
    b: { type: "b" } & { bar: "bar" };
    c: { type: "c" } & { baz: "baz" };
}>;

const obj: VerifiedType = {
    a: { type: "a", foo: "foo" },
    b: { type: "b", bar: "bar" },
    c: { type: "c", baz: "baz" }
}

Upvotes: 2

Related Questions