I have a record of data (object-literal), and I need to calculate the average of several of its properties. For example, consider that each record reflects a sample of dog breeds and their weights. We have one representative for each breed, but two specific breeds may not appear in the data:
// typescript
type dogSample = {
// 5 core breeds that always appear
bulldog: number,
poodle: number,
pug: number,
chihuahua: number,
boxer: number,
// 2 additional that sometimes appear
dalmatian?: number,
rottweiler?: number, // rottweiler could appear only if dalmatian appears, but not vice versa
// other irrelevant properties
foo: string,
bar: boolean,
baz: number
// and potentially hundreds of unrelated properties
}
My goal is to calculate the average of dogs weights. So I target those properties directly with three functions:
const calcMeanCoreFive = (obj: dogSample) =>
(obj.bulldog obj.poodle obj.pug obj.chihuahua obj.boxer) / 5;
const calcMeanCoreFivePlusDalmatian = (obj: Required<dogSample>) => // using `Required` solves type problem
(obj.bulldog
obj.poodle
obj.pug
obj.chihuahua
obj.boxer
obj.dalmatian) /
6;
const calcMeanCoreFivePlusDalmatianPlusRottw = (obj: Required<dogSample>) =>
(obj.bulldog
obj.poodle
obj.pug
obj.chihuahua
obj.boxer
obj.dalmatian
obj.rottweiler) /
7;
Finally I want to add a wrapper around all three versions such that:
const calcMeanDogSample = (obj: dogSample, nBreeds: 5 | 6 | 7) =>
nBreeds === 5
? calcMeanCoreFive(obj)
: nBreeds === 6
? calcMeanCoreFivePlusDalmatian(obj)
// ^
: calcMeanCoreFivePlusDalmatianPlusRottw(obj);
// ^
// argument of type 'dogSample' is not assignable to parameter of type 'Required<dogSample>'
My attempt to work around this
I tried to type calcMeanDogSample()
with Required<dogSample>
:
const calcMeanDogSample2 = (obj: Required<dogSample>, nBreeds: 5 | 6 | 7) =>
nBreeds === 5
? calcMeanCoreFive(obj)
: nBreeds === 6
? calcMeanCoreFivePlusDalmatian(obj)
: calcMeanCoreFivePlusDalmatianPlusRottw(obj);
This solves the error as far as the function's definition. However, calling calcMeanDogSample2()
and passing an object of type dogSample
wouldn't work:
const someDogSample = {
bulldog: 24,
poodle: 33,
pug: 21.3,
chihuahua: 7,
boxer: 24,
dalmatian: 20,
foo: "abcd",
bar: false,
baz: 123,
} as dogSample;
calcMeanDogSample2(someDogSample, 6);
// ^
// Argument of type 'dogSample' is not assignable to parameter of type // 'Required<dogSample>'.
// Types of property 'dalmatian' are incompatible.
// Type 'number | undefined' is not assignable to type 'number'.
// Type 'undefined' is not assignable to type 'number'.ts(2345)
My question
Is there a way to type calcMeanDogSample()
differently and solve this problem?
Reproducible code at TS playground
CodePudding user response:
The problem is that there's no correlation between nBreeds
and the input obj
in the type system.
However, TypeScript offers you a feature to inform the compiler that these properties do actually exist within your data if they can pass a conditional check: it's called a user-defined type guard and its return type is called a type predicate. Here's an example of how to use it with your data, and — when you do — you no longer need to provide the nBreeds
argument!
See also the utility types
Required<Type>
andPick<Type, Keys>
type DogSample = {
bulldog: number;
poodle: number;
pug: number;
chihuahua: number;
boxer: number;
dalmatian?: number;
rottweiler?: number;
// other irrelevant properties
foo: string;
bar: boolean;
baz: number;
};
// This is called a user-defined type guard (and the return type is called a type predicate)
function sampleIncludesBreed <T extends DogSample, K extends keyof DogSample>(
obj: T,
breed: K,
): obj is T & Required<Pick<DogSample, K>> {
return typeof (obj as Required<DogSample>)[breed] === 'number';
}
const calcMeanCoreFive = (obj: DogSample) =>
(obj.bulldog obj.poodle obj.pug obj.chihuahua obj.boxer) / 5;
const calcMeanCoreFivePlusDalmatian = (obj: (
DogSample
& Required<Pick<DogSample, 'dalmatian'>>
)) =>
(obj.bulldog
obj.poodle
obj.pug
obj.chihuahua
obj.boxer
obj.dalmatian) /
6;
const calcMeanCoreFivePlusDalmatianPlusRottw = (obj: (
DogSample
& Required<Pick<DogSample, 'dalmatian'>>
& Required<Pick<DogSample, 'rottweiler'>>
)) =>
(obj.bulldog
obj.poodle
obj.pug
obj.chihuahua
obj.boxer
obj.dalmatian
obj.rottweiler) /
7;
const calcMeanDogSample = (obj: DogSample) => {
if (sampleIncludesBreed(obj, 'dalmatian')) {
return sampleIncludesBreed(obj, 'rottweiler')
? calcMeanCoreFivePlusDalmatianPlusRottw(obj) /*
^^^
This type is: (parameter) obj: DogSample & Required<Pick<DogSample, "dalmatian">> & Required<Pick<DogSample, "rottweiler">> */
: calcMeanCoreFivePlusDalmatian(obj); /*
^^^
This type is: (parameter) obj: DogSample & Required<Pick<DogSample, "dalmatian">> */
}
return calcMeanCoreFive(obj); /*
^^^
This type is: (parameter) obj: DogSample */
};
const someDogSample = {
bulldog: 24,
poodle: 33,
pug: 21.3,
chihuahua: 7,
boxer: 24,
dalmatian: 20,
foo: "abcd",
bar: false,
baz: 123,
} as DogSample;
calcMeanDogSample(someDogSample);
Completely unrelated to your question, but related to dogs and data: You might like the API at dog.ceo.
CodePudding user response:
While one could implement your existing algorithm in a way the TypeScript compiler would recognize as type safe, my approach here would be to refactor the algorithm to something a bit more general:
const calcMeanDogSample = (obj: DogSample) => {
let sum = obj.bulldog obj.poodle obj.pug obj.chihuahua obj.boxer;
let nBreeds = 5;
for (let v of [obj.dalmatian, obj.rottweiler]) {
if (typeof v === "number") {
sum = v; nBreeds ;
}
}
return sum / nBreeds;
}
To get the mean, we need to divide the sum
of the relevant properties by how many there are (nBreeds
). We can do the "core five" without worry because they are required properties. For the optionanl dalmatian
and rottweiler
properties, we only want to contribute to sum
and nBreeds
if they are actually present.
You can verify that this behaves as expected:
const someDogSample = {
bulldog: 24,
poodle: 33,
pug: 21.3,
chihuahua: 7,
boxer: 24,
dalmatian: 20,
foo: "abcd",
bar: false,
baz: 123,
};
console.log(calcMeanDogSample(someDogSample)); // 21.55
You could even refactor this more by transforming the list of keys to a list of possibly-defined numbers, filtering out the undefined values, and computing the mean of that:
const mean = (x: number[]) => x.reduce((a, v) => a v, 0) / x.length;
const calcMeanDogSample2 = (obj: DogSample) => mean(
(["bulldog", "poodle", "pug", "chihuahua", "boxer", "dalmatian", "rottweiler"] as const)
.map(k => obj[k])
.filter((v): v is number => typeof v === "number")
);
console.log(calcMeanDogSample2(someDogSample)); // 21.55