Steve B
Steve B

Reputation: 37710

Exhaustive keyof type

I'm working in a typescript application that retrieve data from an api (actually OData api).

The api allow to specify which fields to retrieve. Like api/some/getbyid(42)?$select=x,y,z to get x, y and z fields, along some technical fields that are always retrieved (like id or author)

I have model types that reflect the api output :

type APIItem = {
  id : number; 
  author : string; 
}

type CustomerModel = APIItem  & {
  firstName : string;
  lastName:string;
  age : number
}

I wrap the retrieval logic into an function that ask the ID and the fields to retrieve, call the API and cast the result:

const fakeApi = (id: number, fields : string[]): Promise<APIItem> => {
  // build api URL, fetch, ....
  const result = JSON.parse(`{
    "id": ${id}, "firstName": "Mark", "lastName": "Hamill", "age": 20, "author": "d@some.com"
  }`) ;
  return Promise.resolve(result);
}

const loadFromDB = async <
TModel extends APIItem
>(
  id: number, // Item ID in the api
  fields : (keyof Omit<TModel, 'id'> & string)[] // Fields to retrieve. Omit Id because it will be always included
  ): Promise<TModel> => {

  const fromDb = await fakeApi(id, fields);
  const result = fromDb as TModel;
  return Promise.resolve(result);
}

const customerPromise = loadFromDB<CustomerModel>(42, ['firstName']); // Coding error : missing fields
customerPromise.then(console.log).catch(console.error);

This is working as expected except for one point: the consumer code has to specify all fields to retrieve. In the above example, only the firstName fields is retrieved, resulting in a incomplete object.

Is there any (simple) way to ensure all fields from the model are provided in the API call ?

As far as I know, there's no way in TS to iterate keys in type, because types are not part of the actual JS output.

What I'd like is to ensure the calling code specify ALL fields (or the function be able to guess itself the fields):

loadFromDB<CustomerModel>(42, ['firstName', 'lastName', 'age']);

This way, the models will always be complete.

Playground Link

Upvotes: 1

Views: 105

Answers (1)

T.J. Crowder
T.J. Crowder

Reputation: 1075517

In situations where I want both runtime information and compile-time information, I try to set things up so I can derive the compile-time information from the data I use for the runtime information, since in most cases I can't go the other way (enums being a special case). In particular, "exhaustive arrays" like you describe are problematic, see jcalz's answer about that.

To do that here, you might define CustomerModel in terms of a CustomerModelFields object that has the field names and unused representative values:

// Primary source of truth is an object with the names of the fields and representative unused values
const CustomerModelFields = {
    firstName: "",
    lastName: "",
    age: 0,
};

// Model is derived from the fields object
type CustomerModel = APIItem & typeof CustomerModelFields;

(I haven't included author there because it wasn't in the fields array of your sample call that should work and because, like id, it's part of APIItem. But your code only explicitly excludes id, so add it if it should be there.)

Then loadFromDB accepts that fields object type as its type parameter and uses it for the fields (now it's an object rather than an array of strings), and derives its return type from the fields object:

const loadFromDB = async <Fields extends object>(
    id: number, // Item ID in the api
    fields: Fields // Fields to retrieve. Omit Id because it will be always included
): Promise<APIItem & Fields> => {
    const fromDb = await fakeApi(id, Object.keys(fields));
    const result = fromDb as APIItem & Fields;
    return Promise.resolve(result);
};

When calling it, you only have to say one thing (the fields), not two (the type and fields):

const customer = await loadFromDB(42, CustomerModelFields);

...although in that example, the type of customer would be shown as APIItem & { firstName: string; lastName: string; age: number; }. That's the same as CustomerModel, but doesn't have that alias associated with it. If you wanted that specific alias, you could specify the type on the constant:

const customer: CustomerModel = await loadFromDB(42, CustomerModelFields);

Full example (playground link):

type APIItem = {
    id: number;
    author: string;
};

// Primary source of truth is an object with the names of the fields and representative unused values
const CustomerModelFields = {
    firstName: "",
    lastName: "",
    age: 0,
};

// Model is derived from the fields object
type CustomerModel = APIItem & typeof CustomerModelFields;

const fakeApi = (id: number, fields: string[]): Promise<APIItem> => {
    // build api URL, fetch, ....
    const result = JSON.parse(` {
    "id" : ${id}, "firstName": "Mark", "lastName" : "Hamill", "age": 20, "author" : "d@some.com"
  }`);
    return Promise.resolve(result);
};

const loadFromDB = async <Fields extends object>(
    id: number, // Item ID in the api
    fields: Fields // Fields to retrieve. Omit Id because it will be always included
): Promise<APIItem & Fields> => {
    const fromDb = await fakeApi(id, Object.keys(fields));
    const result = fromDb as APIItem & Fields;
    return Promise.resolve(result);
};

(async () => {
    try {
        const customer = await loadFromDB(42, CustomerModelFields);
        console.log(customer);
        //          ^? const customer: APIItem & { firstName: string; lastName: string; age: number }
    } catch (error: any) {
        console.error;
    }
    // Or with the alias
    try {
        const customer: CustomerModel = await loadFromDB(42, CustomerModelFields);
        console.log(customer);
        //          ^? const customer: CustomerModel
    } catch (error: any) {
        console.error;
    }
})();

Upvotes: 3

Related Questions