2

Let's say we have the following types:

type DNA = {
    G: string;
    C: string;
    T: string;
    A: string;
}

type DNAKeys = keyof DNA;

Is it possible to define a type such that we define every possible combination of string literals that are composed of DNAKeys into a flat string type?

This problem gives me a lot of language syntax definition vibes, and I mostly was curious if it was even possible with current Typescript.

The test scenario would be as follows:

let dna: DNA = {
    G: 'C',
    C: 'G',
    T: 'A',
    A: 'U'
}

const doSomethingWithDNA = (dnaString: DNACombinations) => {
    for(const letter of dnaString){
        console.log(dna[letter]);
    }
}

The only possible thing I can think of is to manually sit there and define an ever increasing dictionary of combinations like this:

type DNACombinations = 
    | DNAKey
    | `${DNAKey}${DNAKey}`
    | `${DNAKey}${DNAKey}${DNAKey}`
    // ad infinitum

Alternatively, like actual syntax checks, this might be something that has to wait until regex types are a thing?

Juan Valencia
  • 38
  • 1
  • 5
  • Does using `type DNACombinations = Partial` works to you? This makes every prop of your type an optional prop so you can use any combination you want – NoNam4 Aug 29 '23 at 13:26
  • See: https://stackoverflow.com/questions/12303989/cartesian-product-of-multiple-arrays-in-javascript/43053803#43053803 you're after a cartesean product – xQbert Aug 29 '23 at 13:32
  • 1
    Without regex types or something like them this is impossible. The closest you can get is a generic constraint that *checks* a string literal, and even this doesn't understand that `letter` is a `DNAKey`. See [this playground link](https://tsplay.dev/mqy0dm). Does that fully address the question? If so I'll write an answer explaining; if not, what am I missing? – jcalz Aug 29 '23 at 13:36
  • @jcalz makes sense, maybe one day the answer to this question will change. – Juan Valencia Aug 29 '23 at 14:05

1 Answers1

1

Without true regular expression validated types as discussed in microsoft/TypeScript#41160, what you are trying to do is impossible. There is no specific type DNACombinations corresponding to strings that consist only of the characters "A", "C", "G", and "T".

The closest you can currently get is to make a generic type ValidDNACombinations<T> that acts like a constraint on T, so that T extends ValidDNACombinations<T> if and only if T would have been assignable to your desired DNACombinations type. Here's one way to write that:

type ValidDNACombinations<T extends string, A extends string = ""> =
    T extends `${infer F}${infer R}` ?
    F extends DNAKeys ? ValidDNACombinations<R, `${A}${F}`> : `${A}${DNAKeys}` :
    A;

That's a tail-recursive conditional type which uses template literal types to parse T into individual characters, checking each one in turn against DNAKeys. If every character matches then the output will just be the same as the input. Otherwise the output will be the longest initial segment of the input that matches, plus a final DNAKeys character at the end. So a bad input will result in a good output which is "close" to it, to hopefully provide helpful error messages.

Then you could write const v: ValidDNACombinations<"CAT"> = "CAT", but that's redundant. So we can make a helper function asDNACombinations that infers the type argument for you. Like this:

const asDNACombinations = <T extends string>(
    t: T extends ValidDNACombinations<T> ? T : ValidDNACombinations<T>
) => t;

That's a bit complicated because TypeScript balks at a more straightforward recursive constraint like <T extends ValidDNACombinations<T>>(t: T) => t. Anyway let's test it out:

const okay = asDNACombinations("GATTACA");

const bad = asDNACombinations("ATTACKING"); // error
// Argument of type '"ATTACKING"' is not assignable to parameter of type
// '"ATTACA" | "ATTACG" | "ATTACC" | "ATTACT"'.

So the good input is accepted, while the bad input is rejected with an error message that shows the problem.


Note that this still doesn't give you the desired behavior whereby splitting the string into characters is known to produce an array of DNAKey values. The compiler is unable to perform the higher-order reasoning to know this. I'm not even sure if this would even be possible with regex types. So you'll need a type assertion or the like to tell the compiler about that:

const doSomethingWithDNA = <T extends string>(
    dnaString: T extends ValidDNACombinations<T> ? T : ValidDNACombinations<T>) => {
    for (const letter of dnaString) {
        console.log(dna[letter as DNAKeys]); // <-- assertion needed
    }
}

But at least it works from the caller's side:

doSomethingWithDNA("CAT"); // okay
doSomethingWithDNA("DOG"); // error

One final note, and this is mentioned in a write-up in microsoft/TypeScript#41160: these sorts of things only really help if your data is static, in the form of string literal types known at compile time, so your TypeScript code looks like doSomethingWithDNA("CAT") where the "CAT" string literal is in the code directly. If, on the other hand, your data is dynamic and never appears directly in the TypeScript code, so your TypeScript code looks like doSomethingWithDNA(getDNAFromSomewhere()), where getDNAFromSomewhere() returns a value of type DNACombinations but not a string literal type, then it's really not very useful at all. You might as well just give DNACombinations a nominal-ish type like the following branded type:

type DNACombinations = string & { __validDNA: true } & Iterable<DNAKeys>;
declare function getDNAFromSomewhere(): DNACombinations;

Then your doSomethingWithDNA implementation works well enough even without an assertion (because I said that it was Iterable<DNAKeys>):

const doSomethingWithDNA = (dnaString: DNACombinations) => {
    for (const letter of dnaString) {
        console.log(dna[letter]); // okay
    }
}

And then as long as you don't care about hardcoded string literals, you'll be happy:

doSomethingWithDNA(getDNAFromSomewhere()) // okay

doSomethingWithDNA("CAT"); // error, but who cares, you won't do this

This doesn't require regex types or generics or anything, and you can do it today.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360