Search code examples
typescripttypescript-generics

How to Ensure TypeScript Enforces Property Constraints Based on Input Object Keys?


I'm working with TypeScript and trying to ensure that the return type of a function accurately reflects the properties that are present in the input object after mapping. Specifically, I want TypeScript to enforce type constraints so that accessing non-existent properties on the returned object results in a compile-time error.

My current code:

enum MetadataKeys {
  NATURE = 'nature',
  BUDGET_YEAR = 'budget_year',
}

const metadataKeysMapper: Record<string, MetadataKeys> = {
  cdNatureza: MetadataKeys.NATURE,
  natureza: MetadataKeys.NATURE,
  cd_natureza: MetadataKeys.NATURE,
  SiglaNatureza: MetadataKeys.NATURE,
  nuAnoOrcamento: MetadataKeys.BUDGET_YEAR,
  ordem_orcamentaria: MetadataKeys.BUDGET_YEAR,
  nu_ano_orcamento: MetadataKeys.BUDGET_YEAR,
  Orcamento: MetadataKeys.BUDGET_YEAR,
  anoOrcamento: MetadataKeys.BUDGET_YEAR,
};

type MetadataResponse<T> = {
  [K in keyof T as K extends keyof typeof metadataKeysMapper
    ? (typeof metadataKeysMapper)[K]
    : never]: K extends keyof typeof metadataKeysMapper ? T[K] : never;
};

export function processMetadata<T extends Record<string, any>>(
  metadata: T,
): MetadataResponse<T> {
  const processedMetadata: Partial<Record<MetadataKeys, any>> = {};

  for (const [key, value] of Object.entries(metadata)) {
    if (key in metadataKeysMapper && value) {
      processedMetadata[metadataKeysMapper[key]] = value;
    }
  }
  return processedMetadata as MetadataResponse<T>;
}

Expected result:

const input = { natureza: 'comum' };
const result = processMetadata(input);

// Accessing valid property
console.log(result.nature); // should work

// Accessing invalid property
console.log(result.budget_year); // should raise a TypeScript error

Problem: The TypeScript compiler is not raising an error when accessing non-existent properties on the result object. I expected accessing result.budget_year to cause a compile-time error.


Solution

  • If you annnotate the type of metadataKeysMapper as Record<string, MetadataKeys> like

    const metadataKeysMapper: Record<string, MetadataKeys> = { ⋯ };
    

    then you are effectively discarding any more specific information from the initializing value. That means keyof typeof metadataKeysMapper is just keyof Record<string, MetadataKeys> which is string. TypeScript has no idea which particular keys might be there, or which values might be at which keys.


    If you want to keep track of the specific properties in the initializer, then you should just let TypeScript infer the type of metadataKeysMapper from that initializer, so you should remove the annotation:

    const metadataKeysMapper = { ⋯ };
    

    If it matters that you check that metadataKeysMapper is assignable to Record<string, MetadataKeys>, you can use the satisfies operator to do so:

    const metadataKeysMapper = { ⋯ } satisfies Record<string, MetadataKeys>;
    

    And furthermore, if you want TypeScript to keep track of which enum members exist at which keys (that is, you want to tell the difference between the literal types of MetadataKeys.NATURE and MetadataKeys.BUDGET_YEAR), then you should use a const assertion to ask for such narrow inference:

    const metadataKeysMapper = { ⋯ } as const satisfies Record<string, MetadataKeys>;
    

    Once you do that, you'll find that processMetadata()'s output behaves as you expected:

    const input = { natureza: 'comum' };
    const result = processMetadata(input);
    console.log(result.nature); // okay
    console.log(result.budget_year); // error!
    //                 ~~~~~~~~~~~
    

    Note that you might need to tweak the implementation of processMetadata because now metadataKeysMapper is not seen as allowing all possible string keys. The easiest way is to widen metadataKeysMapper back to Record<string, MetadataKeys> inside that function before indexing into it:

    function processMetadata<T extends Record<string, any>>(metadata: T) {
    
        // copy to widened variable, 
        const mkm: Record<string, MetadataKeys> = metadataKeysMapper
    
        const processedMetadata: Partial<Record<MetadataKeys, any>> = {};
        for (const [key, value] of Object.entries(metadata)) {
            if (key in mkm && value) { // <-- use mkm here instead 
                processedMetadata[mkm[key]] = value; // <-- use mkm here instead
            }
        }
    
        return processedMetadata as MetadataResponse<T>;
    }
    

    Playground link to code