1

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

Nullndr
  • 1,624
  • 1
  • 6
  • 24
Steve B
  • 36,818
  • 21
  • 101
  • 174
  • 1
    Your example has missing types, plase provite a working example as a link to https://www.typescriptlang.org/play – Dimava Nov 30 '22 at 08:46
  • @Dimava **And** an (optional) link, not just *as* a link. :-) The full MRE must be here, on-site. – T.J. Crowder Nov 30 '22 at 08:48
  • What's missing ? the whole code in my repro is here. Anyways I link the playground code – Steve B Nov 30 '22 at 08:48
  • @SteveB - Indeed, I don't see anything missing either. :-) – T.J. Crowder Nov 30 '22 at 08:49
  • 1
    See my edit. I tried to be clearer – Steve B Nov 30 '22 at 08:54
  • Sounds like you should be using classes and not types? – Dane Brouwer Nov 30 '22 at 08:56
  • 1
    @DaneBrouwer: not sure to see how class can help. It may allow to add some runtime check, but not sure it will add coding type checking. But maybe I miss some point ? – Steve B Nov 30 '22 at 08:58
  • Ahh - so you want both Run-time and Compile-time checks. Yeah, classes wouldn't help with compile-time checks. – Dane Brouwer Nov 30 '22 at 09:00
  • 1
    V. minor side-note: You mentioned casting. TypeScript doesn't have casting, it has *type assertions*, which are similar but also quite different. In languages that have casting, a cast can actually change the runtime value (for instance, in Java casting a `double` to an `int` changes the value from an IEEE-754 double-precision floating point bit pattern to a two's complement bit pattern for the trunc'd integer value). TS's type assertions have **no** runtime effect at all, they're purely you, the programmer, asserting to TypeScript that you know that the thing in question is of the given type. – T.J. Crowder Nov 30 '22 at 09:28

1 Answers1

3

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;
    }
})();
T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • 2
    @DaneBrouwer - I couldn't tell for sure, but given the OP's example call that should work left `"author"` out of the array, I decided to leave it out. Easy enough for the OP to add it if it's meant to be there, but... – T.J. Crowder Nov 30 '22 at 09:44