typescriptgenericstypescript-genericsrecord

Using generics to match a single string literal in Typescript


The class MyClass interfaces with an indexing sub-system that requires each record (Record<string, any>) to contain a specific primary key field. I'm trying to use Typescript's type system to ensure that every record given to MyClass contains this primary key field, by giving it a string literal as a generic parameter. I do not need to use the field's value; only to ensure it exists in each record. Is it possible to craft a class MyClass<T...> such that T... matches a string literal only?

Example usage of MyClass<T>:

class MyClass<T ...> { // Can T match a single string literal?
  add(arg: Record<T, any> & Record<string, any>): void {}
  get(arg: T): Record<T, any> & Record<string, any> {}
};

const myClass = new MyClass<"requiredKey">();
myClass.add({
    requiredKey: "someValue";
    //...
}); // Ok
myClass.add({
    someOtherKey: "someValue";
}); // Rejected by the compiler

I started to define a type which I thought was constrained to being a string literal and not a union, based on a previous answer

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type StringLiteral<T extends string> = [T] extends [UnionToIntersection<T>]
  ? string extends T ? never : string : never;

My test cases:

class MyClass<K extends StringLiteral<K>> {
  constructor() {}
  add(arg: Record<K, any> & Record<string, any>) {}
  get(arg: Record<K, any>): Record<K, any> & Record<string, any> { return null!; }
};

let t = new MyClass<"a">();        // Ok: Valid
let u = new MyClass<"a" | "b">();  // Ok: Invalid `"a" | "b"` does not satisfy `never`
let v = new MyClass<string>();     // Ok: Invalid `string` does not satisfy `never`
let w = new MyClass<1>();          // Ok: Invalid `1` does not extend `string`
let x = new MyClass<`${"a"}`>();   // Ok: String template literals are fine
let z = new MyClass<never>();      // Not ok, but I can live with that...

I am satisfied with this approach; but since I am new to Typescript, and I am wondering if there was a simpler solution.

Currently using typescript 5.5.

LONG EDIT Following the discussion in the comments, I reworded the question to better illustrate the problem. Leave a comment if still unclear.


Solution

  • I'm afraid I still don't see the use case that requires the type to be a single string literal type, but in what follows I'll see how close we can get.


    First, we can write a utility type IsUnion<T, Y, N> to detect if an input type T is a union and return Y if yes and N if no:

    type IsUnion<T, Y = unknown, N = never, U = T> =
        T extends unknown ? [U] extends [T] ? N : Y : never
    

    This is a distributive conditional type in T. It splits T into its union members, and checks each member against the whole union U (which uses a default type argument to be a non-distributed copy of T). If the whole union is assignable to each member, that means there is only one member and we return N. Otherwise we return Y.


    Then we can write a StrLit<T, Y, N> utility type to detect if the (presumed non-union) input string type T is a string literal type, or if it's something wider like string or a template literal pattern type (as implemented in microsoft/TypeScript#40498) like `foo${string}`:

    type StrLit<T extends string, Y = unknown, N = never> =
        T extends `${infer F}${infer R}` ? (
            ["0", "1"] extends [F, F] ? N : StrLit<R, Y, N>
        ) : T extends "" ? Y : N
    

    There's not really a clean way to do this. The only way I've found is to use template literal types to parse T one "character" at a time. If any character is something wide then it's not a string literal. By "something wide" I mean either string or one of the other possible pattern template literal types like `${number}` or `${bigint}`. My test here is: if both "0" and "1" are assignable to it, then it's wide. (Note that "0" and "1" are both valid strings and valid serialized numbers and valid serialized bigints). When we're finished parsing we should end up with "", which means we've found a string literal. If not (because the last "character" of the string type is wide) then we haven't.


    And now we can write the utility checking type SingleKey<T> that evaluates to unknown if T (presumed to be an object type) has a single string literal key, and never otherwise:

    type SingleKey<T> = IsUnion<keyof T, never,
        keyof T extends string ? StrLit<keyof T> : never>
    
    class MyClass<T extends object & SingleKey<T>> {
      constructor() { }
      functionThatMeetsMyGoal(arg: T) { return arg }
    }
    

    Let's test it:

    new MyClass<{key: any}>(); // okay   
    new MyClass<{key1: any; key2: any}>(); // error
    new MyClass<{ [k: `abc${number}def`]: string }>() // error
    new MyClass<{ [k: number]: string }>() // error
    

    Looks good.


    There you go. I wouldn't recommend trying to really use this to guarantee single keys in a type.

    First of all, nothing would prevent a value of such a type from having more than one key, because TypeScript does not have "exact types" as described in microsoft/TypeScript#. You can always widen types with extra properties to ones without them:

    let x = { key: "abc", oopsie: 123 };
    let y: { key: any } = x; // this is allowed
    

    So such types aren't really helpful in preventing runtime values from having more properties than you expect.

    And more importantly, the above utility types are complex and probably fragile. TypeScript does not really intend to allow you to query "is this a single string literal", and the implementation above depends on knowing exactly what subtypes of string TypeScript supports and uses tricks to distinguish them. Future versions of TypeScript might add or change string types in such a way as to break this. So I'd stay away from anything like this in any sort of production system that depends on it. It's fun to explore, but not something you should rely on.

    Playground link to code