Home > OS >  How to get a type from a union where each type has an id
How to get a type from a union where each type has an id

Time:10-28

in my typescript app:

I have sevral sensors and each of them has a unique metric type (like an id) and the type of the value of the metric is unique to each sensor:

type SensorAMetric = {
    sensorId: 'ABC',
    value: [number, number]
}
type SensorBMetric = {
    sensorId: 'DEF',
    value: { foo: string, bar: number }
}
type Metric = SensorAMetric | SensorBMetric

When I receive a metric from my webSockets, I store it inside an object where keys are spensor ids

const metrics: {
    'ABC': SensorAMetric[],
    'DEF': SensorBMetric[]
} = {...}

I managed to be less specific by typing metrics like so

const metrics: {  readonly [Property in Metric['sensorId']]: (Metric & { sensorId: Property })[] } = {...}

But I can't find a way to write a function getSensorMetrics(sensorId) where the type of the return depends on the sensorId.

Here is what I've tried

function getSensorMetrics(id: Metric['sensorId']): (Metric & { sensorId: typeof id })[] 
{
    return metrics[id]
}

But I have this result:

getSensorMetrics('ABC')[0].value // [number, number] | {foo: string, bar: number}

Instead of :

getSensorMetrics('ABC')[0].value // [number, number]

CodePudding user response:

The problem is that the id parameter in getSensorMetrics() is explicitly of type Metric['sensorId'], which is a union. And typeof id is just that union; it is nothing narrower than that. So your return value will also be of the full union type.

If you want id to possibly be seem as some subtype of Metric['sensorId'], then you should make getSensorMetrics() generic in the type of id. Like this:

function getSensorMetrics<T extends Metric['sensorId']>(id: T) {
  return metrics[id];
}

Now we have a type parameter T corresponding to the type of id. It is constrained to Metric['sensorId'], so you know it has to be something assignable to that type. But when you call getSensorMetrics("ABC") the compiler will infer T to be "ABC".

You can see that this works as expected:

const sensorMetrics = getSensorMetrics('ABC');
const two = sensorMetrics[0].value.length; // okay

So that's fine and we could end here if we want. From here on out there are just changes that I think improve things for callers but are not necessary, at least for the example code posted here.


If you inspect the return type of getSensorMetrics() inferred by the compiler, you'll see it leaves a little something to be desired:

/* function getSensorMetrics<T extends "ABC" | "DEF">(id: T): {
    readonly ABC: (SensorAMetric & {
        sensorId: "ABC";
    })[];
    readonly DEF: (SensorBMetric & {
        sensorId: "DEF";
    })[];
}[T] */

const sensorMetrics = getSensorMetrics('ABC');
/* const sensorMetrics: (SensorAMetric & {
    sensorId: "ABC";
})[] */

That is accurate, but probably not what you want people to see.

If you have a union Metric and you want to pick just the member(s) of that union with a sensorId of type T, you should probably use the Extract<T, U> utility type. Your version with an intersection is conceptually the same, but the compiler doesn't always reduce intersections in nice ways (so you'd end up with SensorAMetric & {sensorId: "ABC"} instead of SensorAMetric).

So you could improve this a bit by changing the definition of metrics:

const metrics: {  readonly [K in Metric['sensorId']]: 
  Extract<Metric, { sensorId: K }>[] 
} = null!

function getSensorMetrics<T extends Metric['sensorId']>(id: T) {
  return metrics[id];
}
/* function getSensorMetrics<T extends "ABC" | "DEF">(id: T): {
  readonly ABC: SensorAMetric[];
  readonly DEF: SensorBMetric[];
}[T]*/

const sensorMetrics = getSensorMetrics('ABC');
/* const sensorMetrics: SensorAMetric[] */

So that's definitely nicer at the end. I'm thinking that the IntelliSense of the return type for getSensorMetrics() is still a bit busy, since it spells out the type of metrics. We can just annotate the return type using Extract directly, like this:

function getSensorMetrics<T extends Metric['sensorId']>(
  id: T
): Extract<Metric, { sensorId: T }>[] {
  return metrics[id] as Extract<Metric, { sensorId: T }>[];
}

const sensorMetrics = getSensorMetrics('ABC');
/* const sensorMetrics: SensorAMetric[] */

That's better.

Note that I used a type assertion to tell the compiler that metrics[id] is of the expected return type. The compiler isn't good at understanding when a specific value (like metrics[id]) is assignable to a type that depends on an unresolved generic type parameter, so you often need to assert it. This is a current limitation of TypeScript; see microsoft/TypeScript#33912. The sort of analysis you'd hope to see where, say, T is narrowed to each of "ABC" and "DEF" in turn and inspected against metrics[id] for each case does not happen; single lines like metrics[id] get analyzed once, and so the compiler sometimes loses correlations between different expressions of union type; see microsoft/TypeScript#30581.

Okay, I'd better stop there.


Playground link to code

  • Related