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.
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>;
}