Construct object with new type conforming to another type

168 Views Asked by At

I'd like to construct a bunch of different objects that must each conform to a pre-specified type but which themselves each have a type unique to its specific structure. Moreover, I'd like the ability to enforce conformance to the pre-specified type at the place where the object is constructed.

Consider the following code, which converts a structure that defines APIs into the APIs themselves, each of which is statically type-checked:

type ApiTemplate = {
  [funcName: string]: {
    call: (data: any) => void;
    handler: (data: any) => void;
  }
};

const apiConfig1 = /* see below */;
const apiConfig2 = /* see below */;
const apiConfig3 = /* see below */;

function toApi<T extends ApiTemplate>(
  config: T
): { [n in keyof T]: T[n]["call"] } {
  return Object.fromEntries(
    Object.entries(config).map(
      ([funcName, apiDef]) => [funcName, apiDef.call]
    )
  ) as any;
}

const api1 = toApi(apiConfig1);
const api2 = toApi(apiConfig2);
const api3 = toApi(apiConfig3);

api1.func1(2); // statically typed function signature

I want to define each apiConfig* object in a separate file, and I'd like type static checking available from the editor while coding up the objects. I'd also like the compiler to identify errors within the objects. If I didn't want such type checking, I could just define the APIs as follows:

const apiConfig1 = {
  func1: {
    call: (count: number) => console.log("count", count),
    handler: (data: any) => console.log(data),
  },
  func2: {
    call: (name: string) => console.log(name),
    handler: (data: any) => console.log("string", data),
  },
};

However, if there is a mistake in the object construction, such as mispelling 'handler' as 'handle', TypeScript reports the error at the call to toApi(), which is not where I want the error.

I tried the following:

const apiConfig1 = (function <T extends ApiTemplate>(): T {
  return {
    func1: {
      call: (count: number) => console.log("count", count),
      handler: (data: any) => console.log(data),
    },
    func2: {
      call: (name: string) => console.log(name),
      handler: (data: any) => console.log("string", data),
    },
  };
})();

But I get this error message:

Type '{ func1: { call: (count: number) => void; handler: (data: any) => void; };
func2: { call: (name: string) => void; handler: (data: any) => void; }; }'
is not assignable to type 'T'.
  '{ func1: { call: (count: number) => void; handler: (data: any) => void; };
  func2: { call: (name: string) => void; handler: (data: any) => void; }; }'
  is assignable to the constraint of type 'T', but 'T' could be instantiated
  with a different subtype of constraint 'Template'.ts(2322)

And when I tried this:

const apiConfig1 = (function <T>(): T extends ApiTemplate ? T : never {
  return {
    func1: {
      call: (count: number) => console.log("count", count),
      handler: (data: any) => console.log(data),
    },
    func2: {
      call: (name: string) => console.log(name),
      handler: (data: any) => console.log("string", data),
    },
  }
});

I get this error message:

Type '{ func1: { call: (count: number) => void; handler: (data: any) => void; };
func2: { call: (name: string) => void; handler: (data: any) => void; }; }'
is not assignable to type 'T extends ApiTemplate ? T : never'.ts(2322)

Is there a way to do what I want in TypeScript?

NOTE: The following approach eliminates static type-checking from the API that toApi() generates because it discards each object's unique type:

const apiConfig1: ApiTemplate = /* ... */;
2

There are 2 best solutions below

2
iz_ On

I think you want a simple type annotation:

type ApiTemplate = {
  [funcName: string]: {
    call: (data: any) => void;
    handler: (data: any) => void;
  }
};

const apiConfig1: ApiTemplate = {
  func1: {
    call: (count: number) => console.log("count", count),
    handler: (data: any) => console.log(data),
  },
  func2: {
    call: (name: string) => console.log(name),
    handler: (data: any) => console.log("string", data),
  },
};

Changing handler to handle for example shows the error directly where the typo is.

0
Joe Lapp On

I found one way to do this, by writing a function that both asserts the base type of its parameter and returns the original type of the parameter:

function assertTemplate<T extends ApiTemplate>(template: T) {
  return template;
}

Then define the object as an argument to the function:

const apiConfig1 = assertTemplate({
  func1: {
    call: (count: number) => console.log("count", count),
    handler: (data: any) => console.log(data),
  },
  func2: {
    call: (name: string) => console.log(name),
    handler: (data: any) => console.log("string", data),
  },
  func3: { // <-- errors here saying 'call' is missing
    handler: (data: any) => console.log("any", data)
  }
});

However, I wish I understood why there isn't a more direct way to do this.