Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type safety with TemplateStringsArray and tag functions #33304

Open
5 tasks done
kiprasmel opened this issue Sep 8, 2019 · 19 comments · May be fixed by #49552
Open
5 tasks done

Type safety with TemplateStringsArray and tag functions #33304

kiprasmel opened this issue Sep 8, 2019 · 19 comments · May be fixed by #49552
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@kiprasmel
Copy link

kiprasmel commented Sep 8, 2019

Search Terms

TemplateStringsArray, type, safety, generic, tag, function

Suggestion

Hello, I'd like to know how could I achieve type safety with TemplateStringsArray and tag functions or make such scenarios possible.

Use Cases

I would use TemplateStringsArray together with the

tagFunction`<type_safe_accessor>`

Currently, there's no way to do this, since

  1. the definition of TemplateStringsArray is not generic
// source code:
// TypeScript/src/lib/es5.d.ts
// TypeScript/lib/lib.es5.d.ts

// local install:
// /usr/lib/code/extensions/node_modules/typescript/lib/lib.es5.d.ts

interface TemplateStringsArray extends ReadonlyArray<string> {
    readonly raw: ReadonlyArray<string>;
}

AND

  1. the usage of
tagFunction`<type_safe_accessor>`

gives the following error, completely preventing the type-safe usage:

Argument of type '{}' is not assignable to parameter of type keyof TypeAndValueObject | TemplateStringsArray<keyof TypeAndValueObject>'.
  Type '{}' is missing the following properties from type 'TemplateStringsArray<keyof TypeAndValueObject>': raw, length, concat, join, and 19 more. ts(2345)

Examples

The working case

I have some type-safe i18n translations:

// Dictionary.ts
export interface Dictionary {
	"Hello": string;
	"Click to see more": string;
	"Today is": (day: string) => string;
}

// en.ts
import { Dictionary } from "./Dictionary";

export const en: Dictionary = {
	"Hello": "Hello",
	"Click to see more": "Click to see more",
	"Today is": (day: string) => `Today is ${day}`,
};

// lt.ts
import { Dictionary } from "./Dictionary";

export const lt: Dictionary = {
	"Hello": "Sveiki",
	"Click to see more": "Paspauskite, kad pamatytumėte daugiau",
	"Today is": (day: string) => `Šiandien yra ${day}`,
};
// i18n.ts
import { Dictionary } from "./Dictionary";
import { en } from "./en";
import { lt } from "./lt";

export interface ITranslations {
	en: Dictionary;
	lt: Dictionary;
}

export const translations: ITranslations = {
	en: en,
	lt: lt,
};

// "en" | "lt"
export type ILang = keyof ITranslations;

I have a function to get the translations:

import { ILang, translations } from "./i18n";
import { Dictionary } from "./Dictionary";

const currentLang: ILang = "en";

const dictionary: Dictionary = translations[currentLang];

export const selectTranslation = <K extends keyof Dictionary>(key: K): Dictionary[K] => {
	const translationText: Dictionary[K] = dictionary[key];
	return translationText;
};

And I can safely use it with type-safe translations in the following fashion:

// someFile.ts
import { selectTranslation } from "../selectTranslation.ts";

/**
 * Type-checks, auto-completes etc. the strings from the `Dictionary`
 */
const someText: string = selectTranslation("Hello");

The problem

However, now I'd like to use the template string (template literal) syntax, paired with the tag function to achieve the same functionality, so I improve the selectTranslation function:

// selectTranslation.ts
import { ILang, translations } from "./i18n";
import { Dictionary } from "./Dictionary";

const currentLang: ILang = "en";

const dictionary: Dictionary = translations[currentLang];

export const selectTranslation = <K extends keyof Dictionary>(key: K | TemplateStringArray): Dictionary[K] => {
	let realKey: K;

	if (Array.isArray(key)) {
		realKey = key[0];
	} else {
		realKey = key; // error:
		/** 
		 * Type 'K | TemplateStringsArray<string>' is not assignable to type 'K'.
  		 * Type 'TemplateStringsArray<string>' is not assignable to type 'K'. ts(2322)
		*/
	}

	const translationText: Dictionary[K] = dictionary[realKey];
	return translationText;
};
// anotherFile.ts
import { selectTranslation } from "../selectTranslation.ts";

/**
 * Does NOT type-check and gives the previously mentioned error:
 * 
 * Argument of type '{}' is not assignable to parameter of type K | TemplateStringsArray<K>'.
 * Type '{}' is missing the following properties from type 'TemplateStringsArray<K>': raw, length, concat, join, and 19 more. ts(2345)
 */
const someText: string = selectTranslation`Hello`; // error

Possible solutions

I've tried 3 different solutions, neither of them giving me the results that I want:

a) Change the TemplateStringsArray to be a generic like so

// source code:
// TypeScript/src/lib/es5.d.ts
// TypeScript/lib/lib.es5.d.ts

// local install:
// /usr/lib/code/extensions/node_modules/typescript/lib/lib.es5.d.ts

interface TemplateStringsArray<T = string> extends ReadonlyArray<T> {
    readonly raw: ReadonlyArray<T>;
}

and used it like so

// selectTranslation.ts
-export const selectTranslation = <K extends keyof Dictionary>(key: K | TemplateStringsArray): Dictionary[K] => {
+export const selectTranslation = <K extends keyof Dictionary>(key: K | TemplateStringsArray<K>): Dictionary[K] => {

however, the same problems persisted - the casting from key to realKey failed
AND the tagged function usage still failed

// selectTranslation.ts

if (Array.isArray(key)) {
	realKey = key[0];
} else {
	realKey = key; // still errors
}

// anotherFile.ts
const someText: string = selectTranslation`Hello`; // still errors

b) Instead of using TemplateStringsArray, just use Array<K>

// selectTranslation.ts
-export const selectTranslation = <K extends keyof Dictionary>(key: K | TemplateStringsArray): Dictionary[K] => {
+export const selectTranslation = <K extends keyof Dictionary>(key: K | Array<K>): Dictionary[K] => {

the first problem of casting from key to realKey disappeared,
BUT the second one still remained

// selectTranslation.ts

if (Array.isArray(key)) {
	realKey = key[0];
} else {
	realKey = key; // all cool now
}

// anotherFile.ts
const someText: string = selectTranslation`Hello`; // still errors!

c) Just use any

// selectTranslation.ts
-export const selectTranslation = <K extends keyof Dictionary>(key: K | TemplateStringsArray): Dictionary[K] => {
+export const selectTranslation = <K extends keyof Dictionary>(key: K | any): Dictionary[K] => {

which then allows me to use the tag function, BUT there's NO type-safety, autocompletions etc., making it practically useless.

TL;DR:

Neither of the solutions helped - I'm still unable to use the selectTranslation function as a tag function with type safety.

Is there any way to make it possible?

Reminder - we have 2 problems here:

  1. (less important since it can be avoided by using Array<K>) TemplateStringsArray does not work like it should (or I'm using it wrong), even when used as a generic
  2. (more important) tag functions cannot have type-safe parameters?

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

I'm happy to help if you have any further questions.

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Sep 12, 2019
@RyanCavanaugh
Copy link
Member

@DanielRosenwasser any pre-discussion thoughts?

@DanielRosenwasser
Copy link
Member

Seems related to #16552 (maybe it's a duplicate).

@DanielRosenwasser
Copy link
Member

I think the big problem is that you sort of want to create something that combines TemplateStringsArrays with tuples of literal types, but it's not clear to me how to represent that. Maybe

ReadonlyArray<"some" | "strings"> & { raw: ["some", strings"], 0: "some", 1: "strings" }

but that looks awful.

@trusktr
Copy link
Contributor

trusktr commented Sep 23, 2020

Now that Template String Types are coming out, it would be great if the array was typed as a tuple of string literal types.

Then we would have strict typing and be able to do cool things like you can imagine from the following:

2020-09-21_22-19-45

(Image courtesy of @0kku)

@justinfagnani
Copy link

What would great to have here is for TemplateStringsArray to be parameterized by the tuple type of it's literals.

So the type of strings in:

html`<div>${'a'}</div>`

would be:

TemplateStringsArray<['<div>', '</div>']>

@trusktr
Copy link
Contributor

trusktr commented Sep 24, 2020

If we were to write

foo`one\\n${1}two${2}three`

we'd want to be able to work with a tuple type something like

type TheStrings = readonly ['one\n', 'two', 'three'] & {
  raw: readonly ['one\\n', 'two', 'three']
}

But how can a generic function work with a particular specific type if the type signature is generic?

So if we write something like

function foo<T extends string[]>(strings: T) {}

then the code we write in foo can't do anything more specific with T.

How would it work?

@0kku
Copy link

0kku commented Sep 24, 2020

type TemplateStrignsArrayOf<T extends ReadonlyArray<string>> = TemplateStringsArray & T;

I'd imagine something like this would be fine?

As Trusktr mentioned above, I've been working on writing a fully featured XML type parser in TS, but the only missing puzzle piece is this issue. I'd like this:

declare function xml<
    T extends TemplateStringsArray,
    K extends ReadonlyArray<unknown>,
> (
    strings: T,
    ...slots: K,
): baz;

xml`
  <div class='${foo}'>${bar}</div>
`;

to have it infer T as TemplateStringsArrayOf<["<div class='", "'>", "</div>"]> and K as [typeof foo, typeof bar]. Seems like such an obviously useful thing that it frankly didn't even occur to me that this wouldn't be supported already when I started writing the XML type parser.

@trusktr
Copy link
Contributor

trusktr commented Sep 24, 2020

But the issue with that approach is (unless I missed something) that the code can not forecast what type T will be, in order to type check it in its implementation. So even if T will be passed in as a specific type, the impl doesn't know that specific type, and we'd need to use type narrowing (which I think would mean many many permutations of conditionals, right?).

Here's one way I think it could work:

When foo`one${something}two` is called, TypeScript could pass the type of the string parts (["one", "two"]) as a generic into foo, and foo's implementation should have a constraint that type checks it.

Example:

type AttributeNames = 'foo' | 'bar' | 'baz'
type Attribute<S extends string> = `${AttributeNames}="${S}"`
type TagNames = 'div' | 'p' | 'span'
type OpenTag<T extends TagNames, S extends string = ""> =
  S extends "" ? `<${T}>` : `<${T} ${Attribute<S>}>`
type CloseTag<T extends TagNames> = `</${T}>`

type FullTag<T extends TagNames, S extends string = ""> = `${OpenTag<T, S>}${CloseTag<T>}`

declare function html<T extends FullTag<TagNames, 'asdf'>[]>(
  strings: T,
  ...values: any[]
): any
// When TypeScript sees html`asdf`, TypeScript will call our function like the following:
html([`asdf`]) // ERROR (good)

// When TypeScript sees html`<div foo="asdf"></div>`, TypeScript will call our function like the following:
html([`<div foo="asdf"></div>`]) // NO ERROR (good)

(playground link)

where in each html call, the type of T is checked against the constraint that we defined. If not by checking constraints, how else can we do the actual type checks?

That example is also fairly simple. How can we allow any attribute value, instead of hard coding particular attribute values like asdf in my example?

@trusktr
Copy link
Contributor

trusktr commented Sep 24, 2020

I tried to make the template more generic with T extends string[] (for testing) then tried to see if I could check the type of T with a function that does type narrowing with the is operator, but my attempt failed. Can that be done?

@trusktr
Copy link
Contributor

trusktr commented Sep 24, 2020

@dragomirtitian added return types, so in

const div = html`<div>...</div>`
const p = html`<p>...</p>`

the type of div would be inferred to HTMLDivElement, and p will be HTMLParagraphElement.

playground example

What I mean by "would be" is that currently, it works only if we call html(['<div>...</div>']) but not when we call it as html`<div>...</div>`.

@voxpelli
Copy link

Duplicate of #31422?

@trusktr
Copy link
Contributor

trusktr commented Nov 20, 2020

@DanielRosenwasser @RyanCavanaugh Would you mind looking at this again (with the new example from above) in context of TS 4.1 Template String types?

This currently works:

const div = html(['<div>...</div>']) // div has implicit type HTMLDivElement, yaaay!
const p = html(['<p>...</p>']) // p has implicit type HTMLParagraphElement, yaaay!

but this doesn't:

const div = html`<div>...</div>` // type error, ... is not assignable to TemplateStringsArray
const p = html`<p>...</p>` // type error, ... is not assignable to TemplateStringsArray

(see the previous example for the idea)

This issue can be re-treated as a feature request to allow for template tag functions to receive better types when called as a template tag.

As the example shows, the template tag functions should be called with a similar type as when they are called as a regular function.

@justinfagnani
Copy link

Now that #41891 has given template literals a template literal type, can that be extended so that TemplateStringsArray is generic on that type?

What we would want from that is to be able to compute the variables type based on the strings type:

export const html = <T extends TemplateStringsArray<S>, S extends TemplateLiteral>(
  strings: T,
  ...values: HTMLTemplateTypes<S>) => {
  // ...
}

I think we would need a type like TemplateLiteral with utility types to extract the strings and values types, and in this examples HTMLTemplateTypes would compute the value types based on the string types.

@ahejlsberg

@imjamesb
Copy link

Is there any news on this?

@stephenh
Copy link

stephenh commented Apr 10, 2022

Fwiw @DanielRosenwasser the "not a full GraphQL parser" :-) use case that I'd like to use a type-literal-aware TemplateStringsArray for is to make a type-safe version of the ruby %w[foo bar zaz] syntax.

Here's a working example with a non-TemplateStringsArray function:

type ToWords<S extends string> =
  S extends `${infer H} ${infer T}`
    ? H | `${ToWords<T>}`
    : S;

function w<S extends string>(strings: S): ToWords<S>[] {
  return strings.split(" ") as ToWords<S>[];
}

const words = w(`foo bar zaz`);

This types words as ["foo" | "bar" | "zaz"][]. Neat!

But imo it would be even more neat as a tagged template:

const words = w`foo bar zaz`;

Thanks!

(...also, reading #45310 it seems like people are already "parsing full GraphQL queries" with template type literals, by just using the same gql("...query...") approach as I've done with w("foo bar zaz"). So it seems like the GraphQL parsing cat is already out of the proverbial bag, and I'm not sure it's worth the language inconsistency (that these two features don't work well together) to prevent what users are already working around/doing anyway.)

@reverofevil
Copy link

reverofevil commented May 23, 2022

Tagged template literals should be typechecked as regular function calls.

declare const tag: <T extends readonly any[], U extends any[]>(t: T, ...u: U) => [T, U];

// code
tag`1${2}3\u${'4'}`;

// typechecked as if
declare const literals: readonly ['1', undefined] & {raw: readonly ['1', '3\\u']};
declare const args: [2, '4'];
tag(literals, ...args);

Return type of this call is inferred as

[
    readonly ["1", undefined] & {raw: readonly ['1', '3\\u']},
    [2, '4']
]

so it doesn't seem like there is any loss of information anymore.

Also I'd like to comment on Design Meeting Notes. It's mentioned that use cases are not obvious. The most obvious use case is to get typed and context-aware substitutions into SQL queries.

for (const {...} of query`SELECT * FROM widgets WHERE id = ${id}`) { ... }

This would allow to finally get typed SQL queries in Node. Currently the only way to do typed SQL is to use some kind of an ORM.

Parser for SQL strings doesn't have to be implemented by direly abusing type system. It's sufficient to provide a set of overloads for aforementioned query function. It can be done either by a language plugin (for IDE support), or code generator running once in a while.

Of course, there are other strings that need context-based substitution checking, such as GraphQL queries, XML/HTML templates and grammars' parser actions that cannot be typed at the moment.

The implementation should be quite straightforward, plays nicely with recent additions to template string literal inference, and would fix a long-standing issue of TemplateStringArray being incorrectly typed.

@DanielRosenwasser Could you please reconsider it on the next design meeting?

@wmertens
Copy link

Seems like this almost works but not quite? The below errors with

Argument of type 'TemplateStringsArray' is not assignable to parameter of type 'readonly ["\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n"] & { raw: readonly ["\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n"]; }'.
      Property '0' is missing in type 'TemplateStringsArray' but required in type 'readonly ["\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n"]'.ts(2769)

...

The call would have succeeded against this implementation, but implementation signatures of overloads are not externally visible.
import * as types from './graphql.js'
import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

const documents = {
	'\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n': types.FooDocument,
}

export function gql(source: string): unknown
export function gql(
	source: '\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n'
): typeof documents['\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n']

// Try to define a static TemplateStringsArray
export function gql(
	tmpl: readonly ['\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n']
	& {raw: readonly ['\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n']}
): typeof documents['\n  fragment Lel on Tweet {\n    id\n    body\n  }\n']

export function gql(source: string | TemplateStringsArray) {
	return (documents as any)[
		typeof source ==='string' ? source : source[0]
	] ?? {}
}

// works
const t = gql('\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n')
// fails
const t2 = gql`\n  query Foo {\n    Tweets {\n      id\n    }\n  }\n`

@mindplay-dk
Copy link

It looks like template string types are somehow "lossy"?

image

playground 👈

Constant strings and numbers are widened to just strings and numbers, and constant string fragments are flattened to TemplateStringsArray - while the latter is clearly working as you intended it to, there must be some way we can open this up to more exciting use cases.

My hope is we would be able to parse SQL template literals at compile-time, so that e.g. sql`SELECT name, age FROM users` could be inferred as Array<{name: string, age: number}> based on a schema-type bound to sql.

Someone attempted to start a project here, but it works with string literals only - but imagine the awesome simplicity and power of something like sql-template-strings with that level of type inference.

(I'm aware of kysely, which is definitely impressive, but this approach is a lot more complex than adding compile-time type-checking over the ~50 lines of run-time code required for SQL template strings.)

@DerekZiemba
Copy link

DerekZiemba commented Dec 10, 2024

This has bugged me for a while. I'm faced with two options:

  • I don't use a tagged template & miss out on syntax highlighting. But the return type is correct.
  • I use a tagged template & I get syntax highlighting, but no typing.

My work around is a hack where the first value passed into a tagged template is the expected return type. The only thing stopping things from working is TemplateStringsArray widening to string & losing the literal type. If it was just a readonly array or even just an array, it'd work fine as you can see from my example where I just call the template function with parenthesis & it can pick up on the type.

Image

I'm using JSDoc & plain JS as I find TS often just gets in the way.

/**
  * @template {string} T - The string to be trimmed.
  * @template {string} [C=' '|'\t'|'\n'] - The characters to be trimmed from the start of the string. Defaults to space, tab, or newline.
  * @typedef {T extends `${C}${`${C}${C}${C}`|`${C}`|''}${infer X}` ? TrimStart<X, C>: T} TrimStart
  */
/**
  * @template {string} T - The string to be trimmed.
  * @template {string} [C=' '|'\t'|'\n'] - The characters to be trimmed from the start of the string. Defaults to space, tab, or newline.
  * @typedef {T extends `${infer X}${`${C}${C}${C}`|`${C}`|''}${C}` ? TrimEnd<X, C>: T} TrimEnd
  */
/**
  * @template {string} T - The string to be trimmed.
  * @template {string} [C=' '|'\t'|'\n'] - The characters to be trimmed from the start of the string. Defaults to space, tab, or newline.
  * @typedef {TrimEnd<TrimStart<T, C>, C>} Trim
  */
/**
  * @template {string} HTML
  * @template {any} D - Default return value if fails to parse
  * @typedef {Trim<HTML> extends infer S extends string ?
  *    S extends `<${infer K extends keyof HTMLElementTagNameMap} ${string}` ? HTMLElementTagNameMap[K] :
  *    S extends `${string}</${infer K extends keyof HTMLElementTagNameMap}>` ? HTMLElementTagNameMap[K] :
  *    S extends `<${infer K extends keyof HTMLElementTagNameMap}>` ? HTMLElementTagNameMap[K]
  *    : D
  *  : D} ParseHTMLElementType
  */
/**
 * @type {{
 * <VALUES extends readonly [(abstract new (...args: any) => unknown) | typeof Array | typeof HTMLCollection | typeof String, ...any[]]
 * >(raw: TemplateStringsArray, ...values: VALUES):
 *    VALUES extends [(abstract new (...args: any) => infer V), ...infer _] ? V
 *     : VALUES extends [infer V extends Element | Element[] | HTMLCollectionOf<Element>, ...infer _] ? V
 *     : DocumentFragment;
 * <TEMPLATE extends string | string[]>(raw: TEMPLATE):
 *   ParseHTMLElementType<
 *       Trim<TEMPLATE extends string
 *         ? TEMPLATE
 *         : TEMPLATE extends readonly string[]
 *           ? import('../types/ts-toolbelt/sources/String/Join').Join<TEMPLATE>
 *           : never
 *         >, void> extends infer E extends HTMLElement ? E
 *     : DocumentFragment;
 * <TEMPLATE extends string | string[], VALUES extends readonly string[]>(raw: TEMPLATE, ...values: VALUES):
 *   ParseHTMLElementType<
 *       Trim<TEMPLATE extends string
 *         ? TEMPLATE
 *         : TEMPLATE extends readonly string[]
 *           ? import('../types/ts-toolbelt/sources/String/Join').Join<TEMPLATE>
 *           : never
 *         >, void> extends infer E extends HTMLElement ? E
 *     : VALUES extends [(abstract new (...args: any) => infer V), ...infer _] ? V
 *     : VALUES extends [infer V extends Element | Element[] | HTMLCollectionOf<Element>, ...infer _] ? V
 *     : DocumentFragment;
 * }}
 */ // @ts-ignore
var html = function html(raw, ...values) {
  let specificType;
  if (raw.length > 0 && raw[0] === '' && values[0] != null) {
    if (values[0].prototype instanceof Element || values[0] === Array || values[0] === HTMLCollection) {
      specificType = values[0];
      raw = raw.slice(1);
      values.shift();
    } else if (values[0] === String) {
      values.shift();// @ts-ignore
      return String.raw({ raw: raw.slice(1) }, ...values);
    }
  }
  const template  = document.createElement('template');
  template.innerHTML = String.raw({ raw }, ...values);
  const fragment = template.content;
  if (specificType === HTMLTemplateElement) { // @ts-ignore
    return template; 
  }
  if (specificType === HTMLCollection) { // @ts-ignore
    return fragment.children;
  }
  if (specificType === Array) { // @ts-ignore
    return Array.from(fragment.children);
  }
  if (specificType === DocumentFragment) { // @ts-ignore
    return fragment;
  }
  if (fragment.childElementCount === 1) { // @ts-ignore
    return fragment.firstElementChild;
  }// @ts-ignore
  return fragment;
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet