Machinarius
Machinarius

Reputation: 3731

How can I restrict a TypeScript method to receive `keyof` values for fields of a certain type?

I am trying to write a function that will update a field of an object if a value that was passed in changed versus what's already in the object. The closest thing i've got so far is

type KnownCountryKeys = "defaultCountry" | "country";
type IHasCountry = {
  [key in KnownCountryKeys]: Country;
};

export async function maybeUpdateCountry<THasCountry extends IHasCountry>(
  dto: THasCountry,
  countryKey: KnownCountryKeys,
  newCountryCode: string
) {
  // Pseudo-Code

  // Ideally, typescript could validate at build-time that
  // `dto[countryKey]` is in-fact a Country field, from
  // the type annotations alone.
  if (dto[countryKey].code != newCountryCode) {
    const newCountry = //...validateCountry
    dto[countryCode] = newCountry;
  }
}

My main problem with this approach is that dto can not have any more fields other than the ones specified in KnownCountryKeys I get ts2345, but if I try to convert it to an interface to make the type flexible I get ts2464. Furthermore, I'd like to delete KnownCountryKeys because having that whitelist of field names is a code-smell.

My intent is for this function to receive an arbitrary object of any shape, a string key that is validated by TypeScript to be a field of type Country, and the key of the new country so I can decided whether the country must be updated in an entirely type-safe fashion.

Upvotes: 0

Views: 444

Answers (1)

Tomasz Gawel
Tomasz Gawel

Reputation: 8520

If I understood You properly, You look for something like:

type IHasCountry<K extends KnownCountryKeys> = { [key in K]: Country; };

export function maybeUpdateCountry<K extends KnownCountryKeys>(
    dto: IHasCountry<K>, countryKey: K, newCountryCode: string) {
// ...

EDIT:

If you assume that country code is arbitrary string but the DTO should have a field named as country code with type Country, then:

class Country { constructor(readonly name: string) { } };

function maybeUpdateCountry<K extends string, T extends { [key in K]: Country }>(
  dto: T, countryKey: K, newCountryCode: string): void {}


const dto1 = { 'pl_PL': new Country('Poland') };
const dto2 = { 'fr_CH': new Country('Switzerland') };
const dto3 = { 'fr_FR': new Country('France') };


maybeUpdateCountry(dto1, 'pl_PL', 'Pologne'); // OK
maybeUpdateCountry(dto2, 'fr_CH', 'Suisse'); // OK

maybeUpdateCountry(dto2, 'fr_FR', 'Suisse'); 
// ERROR: 
// Argument of type '{ fr_CH: Country; }' is not assignable to parameter of type '{ fr_FR: Country; }'.
//  Property 'fr_FR' is missing in type '{ fr_CH: Country; }' but required in type '{ fr_FR: Country; }'.


maybeUpdateCountry(dto2, 'test', 'Test'); 
// ERROR: 
// Argument of type '{ fr_CH: Country; }' is not assignable to parameter of type '{ test: Country; }'.
//  Property 'test' is missing in type '{ fr_CH: Country; }' but required in type '{ test: Country; }'.

maybeUpdateCountry({ hello: 'bonjour' }, 'test', 'Test');
// ERROR:
// Argument of type '{ hello: string; }' is not assignable to parameter of type '{ test: Country; }'.
//  Object literal may only specify known properties, and 'hello' does not exist in type '{ test: Country; }'.

maybeUpdateCountry({ test: 'Not a country' }, 'test', 'Test');
// ERROR:
// Type 'string' is not assignable to type 'Country'

maybeUpdateCountry({ test: new Country('Testland') }, 'test', 'Test'); // OK

Upvotes: 1

Related Questions