2

TLDR; VERSION

Edit, I have added a Playground, Link at the bottom

I have an array ob objects in TypeScript.

interface Converter<T = Buffer> {
  name: string;
  uuid: CUUID;
  decode: (value: Buffer) => T;
}

const infoConverter: Converter<string> = {
  name: "info",
  uuid: "180a",
  decode: v => v.toString()
};

const pressureConverter: Converter<number> = {
  name: "pressure",
  uuid: "1810",
  decode: v => v.readInt32BE(0)
};

type MyConverters = [Converter<string>, Converter<number>]

const converters: MyConverters = [infoConverter, pressureConverter];

Now I am asking if it possible to write a type that is a union of all the names in that array:

type Name<Converters[]> = "???";
// type Name<MyConverters> =  "info" | "pressure"

and a type that is a generic corresponding to a name

type Value<Converters[], name extends string>
type Value<MyConverters, "pressure"> = number;

EXPLANATION WHAT I AM DOING AND WHY

I am writing a Bluetooth Low Energy (BLE) Library for Node called Sblendid with which you can talk to BLE peripherals. BLE peripherals have Services from which you can read from and write values to (amongst other things). These sort of "Endpoints" of the Service are called Characteristics and they have IDs (UUIDs).

When you read a Characteristic you need to say which UUID you want to address and the return value will be binary data (usually a Buffer in Node).

So to make life easier for users of the library I want to create an API that lets them provide what I call Converters that look like this:

interface Converter<T = Buffer> {
  name: string;
  uuid: CUUID;
  decode: (value: Buffer) => T;
}

type Converters = Converter<any>[];

So now when somebody wants to talk to a Service on a peripheral they should be able to pass their converters to the Service and can now address their Characteristics by a name and will get their decoded values instead of binary data when the read a Characteristic, like so:

const infoConverter: Converter<string> = {
  name: "info",
  uuid: "180a",
  decode: v => v.toString()
};

const pressureConverter: Converter<number> = {
  name: "pressure",
  uuid: "1810",
  decode: v => v.readInt32BE(0)
};

const converters: Converters = [infoConverter, pressureConverter];

const service = new Service(converters);
const info = await service.read("info");
//    ^^^^                       ^^^^ 
//    this will be a string     their name instead of a UUID (like "a002")

I "already" have a working implementation, but as I am writing everything in TypeScript, I need to get the typings right.

What I want to achieve is that if a user provides the converters as in the example, this:

const service = new Service(converters);
const info = await service.read("infoasdasd");
//                               ^^^^^^^^^^

should trigger an error saying that this is not a name in your converters array. Also TypeScript should understand that this

const service = new Service(converters);
const info = await service.read("infoasdasd");
//    ^^^^

is a string, because of what you wrote in your converters.

Because this all seems easy enough, I want users to be able to work without converters. So if they create a Service without converters

const service = new Service();
const info = service.read("180a");
//    ^^^^                 ^^^^
//    Buffer               any CUUID (string | number)

They should be able to pass any Characteristic UUID and will get a Buffer as return value. The implementation works. I am struggling with teaching TypeScript how to type this.

So far I got this:

type CUUID = number | string;

interface Converter<T = Buffer> {
  name: string;
  uuid: CUUID;
  decode: (value: Buffer) => T;
}

type Converters = Converter<any>[];
type NameOrCUUID<C> = C extends Converters ? Name<C> : CUUID;

type Name<C extends Converters> = "info" | "pressure";
type Value<C, N extends NameOrCUUID<C>> = "" | Buffer;

class Service<C> {
  private converters?: Converters;

  constructor(converters?: C) {
    this.converters = converters as any; // Ask on StackOverflow
  }

  public read<N extends NameOrCUUID<C>>(name: N): Value<C, N> {
    if (this.converters) {
      return this.getConvertedValue(name);
    } else {
      return this.getBufferForUUID(name);
    }
  }

  private getBufferForUUID(uuid: CUUID): Buffer {
    return Buffer.from("Foobar", "utf8"); // Dummy Code
  }

  private getConvertedValue<N extends NameOrCUUID<C>>(name: N): Value<C, N> {
    return "Foobar"; // Dummy Code
  }
}

Of course, this is not the actual implementation for the BLE. With this code I am trying to get the typings right. So what I am trying is to tell read that it can accept a NameOrCUUID, which, depending on if the Service class has a generic that is Converters will either a name of the Converters or if there was no generic passed to the Service class will accept any Characteristic UUID.

This works. When I call this code:

const service = new Service(converters);
const info = service.read("info");

const service2 = new Service();
const info2 = service2.read("180a");

and pass the converters from way up there as a parameter it will tell me that I can only pass info or pressure. I I do not pass any converters at all (service2) I can pass any number or string.

But this is only fake because Implementations for

type Name<C extends Converters> = "???";
type Value<C, N extends NameOrCUUID<C>> = "???";

For this, and this and this is the part I cannot figure out.

I would need to tell TypeScript to allow the name properties of any item in Converters likewise with the values, and the values must match the name.

Ok sorry for the wall of text. I hope I could express what I am asking (that last paragraph is the actual question). I wonder if that is even possible in TypeScript.

Here is a Playground:

This elaborate example on typescriptlang.org/play

1
  • 1
    "tuple" might be a good keyword to add here (ie what MyConverters is) Commented Sep 6, 2019 at 15:24

1 Answer 1

1

The first problem I see is that your type annotations of infoConverter as Converter<string> and pressureConverter as Converter<number> are too wide. The compiler will dutifully forget that infoConverter.name is "info", and instead will use the type annotation that says it is a string.

I think you will get closer to what you want by just using type inference and a const assertion to get the narrowest possible types for your converter instances:

const infoConverter = {
  name: "info",
  uuid: "180a",
  decode: (v: Buffer) => v.toString()
} as const;

const pressureConverter = {
  name: "pressure",
  uuid: "1810",
  decode: (v: Buffer) => v / 1024
} as const;

const converters = [infoConverter, pressureConverter] as const;

At this point there's no guarantee that the elements of converters conform to your Converter interface, but you can make sure that any functions that accept converters or types that accept typeof converters are constrained to Converter<any>[] and you'll get an error there if converters has a mistake.

So let's look at those two type functions you want... given an array of Converter types, get the list of names, and a mapping of name to value type. How about:

type Names<C extends ReadonlyArray<Converter<any>>> = C[number]["name"];

type Value<
  C extends ReadonlyArray<Converter<any>>,
  N extends Names<C>
> = Extract<C[number], { name: N }> extends Converter<infer V> ? V : never;

Note that I constrain C to ReadonlyArray<Converter<any>> and not Array<Converter<any>>... this is a looser constraint (the Array interface is an extension of the ReadonlyArray interface; every Array is a ReadonlyArray but not vice versa; the naming is perhaps a bit misleading; maybe it should be ReadableAndWritableArray for Array and ReadableArray for ReadonlyArray... but I digress). That's so that C will accept typeof converters, which is a readonly tuple.

Anyway, the Names type alias looks up the "name" property type from the number-indexed elements of the C array. And the Value type alias Extracts the elements of C that have a name property matching N, and then infers the value type from it.

Does it work?

type TheNames = Names<typeof converters>; // "info" | "pressure"
type InfoValue = Value<typeof converters, "info">; // string
type PressureValue = Value<typeof converters, "pressure">; // number

Looks good to me. Note that the above lines have no compiler errors, so the converters object must be correctly meeting the constraint of being a (possibly readonly) array of Converter elements.

Hopefully you can use this to get your expanded example code working. Good luck!

Link to code

Sign up to request clarification or add additional context in comments.

4 Comments

Ich have a question. I understand why the const assignment is necessary, but when I comment it out, it still works. Which I do not get, because what you are saying totally makes sense!
Can't post a link because the url is too long but if you do const converters = [infoConverter, pressureConverter];// as const; in line 22 it still works!
The const assertions in the infoConverter and pressureConverter definition are helpful, since without them you'd need some other way to make sure that the "info" and "pressure" names don't widen. But once infoConverter and pressureConverter are already narrow, it's true that you don't need a const assertion in the converters definition, since all that does is retain the order of the two entries, and your code doesn't really care about that. I wouldn't say as const is ever necessary; it's just one of the most convenient ways to keep inferences narrow.
Thank you so much for the explanation, you're a legend!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.