Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript constrain generic to string literal type for use in computed object property

Tags:

typescript

I'm trying to write a function which will take a string literal and return an object with a single field whose name is that string literal. I can write a function that does what I want, but I don't know how to express the constraint that its argument type be a string literal.

The closest I've got is using a generic type which extends string. This permits string literal types, but also unions of string literal types and the type string, which I don't want to be passed to my function.

This compiles, and does what I want, provided that K is a string literal type. Note that type assertion wasn't necessary in typescript 3.4 but it is required in 3.5.

function makeObject<K extends string>(key: K): { [P in K]: string } {
  return { [key]: "Hello, World!" } as { [P in K]: string };
}

If K is anything other than a string literal, the return type of this function won't be the same as the type of the value it returns.

The 2 avenues I can imagine for making this work are:

  • constrain K to be only string literals
  • express that the return type be an object with a single field whose name is a value in K (less satisfying, but at least the type of the function will be honest)

Can typescript's type system express either of these?

If I remove the type assertion in typescript 3.5 I get the error:

a.ts:2:3 - error TS2322: Type '{ [x: string]: string; }' is not assignable to type '{ [P in K]: string; }'.

2   return { [key]: "Hello, World!" };
like image 641
stevebob Avatar asked May 30 '19 07:05

stevebob


2 Answers

Update for TypeScript 4.2

The following works:

type StringLiteral<T> = T extends string ? string extends T ? never : T : never;

(No longer works): TypeScript 4.1 template literal type trick

Edit: The below actually broke in 4.2. Discussion here

type StringLiteral<T> = T extends `${string & T}` ? T : never;

TS 4.1 introduced template literal types which allows you to convert string literals to other string literals. You can convert the string literal to itself. Since only literals can be templated and not general strings, you just then conditionally check that the string literal extends from itself.

Full example:

type StringLiteral<T> = T extends `${string & T}` ? T : never;

type CheckLiteral = StringLiteral<'foo'>;  // type is 'foo'
type CheckString = StringLiteral<string>;  // type is never

function makeObject<K>(key: StringLiteral<K>) {
    return { [key]: 'Hello, World!' } as { [P in StringLiteral<K>]: string };
}

const resultWithLiteral = makeObject('hello');  // type is {hello: string;}
let someString = 'prop';
const resultWithString = makeObject(someString); // compiler error.

I don't think unions for K are a problem any more because there is no need to narrow the type for the property key in the makeObject signature. If anything this becomes more flexible.

like image 196
Andy K Avatar answered Nov 16 '22 04:11

Andy K


There is no constraint for something to be a single string literal type. If you specify extends string the compiler will infer string literal types for K but it will also by definition allow unions of string literal types (after all the set of a union of string literal types is included in the set of all strings)

We can create a custom error, that forces as call to be in an error state if it detects a union of string literal types. Such a check can be done using conditional types making sure that K is the same as UnionToIntersection<K>. If this is true K is not a union, since 'a' extends 'a' but 'a' | 'b' does not extends 'a' & 'b'

type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

type CheckForUnion<T, TErr, TOk> = [T] extends [UnionToIntersection<T>] ? TOk : TErr

function makeObject<K extends string>(key: K & CheckForUnion<K, never, {}>): { [P in K]: string } {
    return { [key]: "Hello, World!" } as { [P in K]: string };
}

makeObject("a")
makeObject("a" as "a" | "b") // Argument of type '"a" | "b"' is not assignable to parameter of type 'never'
like image 39
Titian Cernicova-Dragomir Avatar answered Nov 16 '22 02:11

Titian Cernicova-Dragomir