Typescript: Restrict template literal interpolation expressions to strings

Created on 6 Mar 2019  Âˇ  18Comments  Âˇ  Source: microsoft/TypeScript

Search Terms

"template literal"

There are many hits, some which seem related, but everything I could find was a much bigger ask or wider in scope

Suggestion

Add a compiler option to enforce using only strings in ES6 string template literals

Use Cases

When using string literals, any variables are coerced to strings, which can lead to undesirable behavior.

As far as I can tell there's no way to avoid this behaviour. In the spirit of tying everything, I'd prefer that only actual string types are permissible for use in string templates to avoid accidental coercion by passing null, undefined or object types that may have unexpected string representations, and force users to explicitly convert them to strings.

Examples

For example:

function formatName(name: string | null): string {
   return `Name is: ${name}`;
} 

formatName(null)  === "Name is: null"

Ideally the compiler would fail since name can be null.

Checklist

My suggestion meets these guidelines:

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

Most helpful comment

typescript-eslint has a restrict-template-expressions rule to catch this

All 18 comments

So `Step ${i} of ${count}` would have to be written as `Step ${i.toString()} of ${count.toString()}`?

I actually think this is a context where coercion to string is clearly expected, and it makes template literals easier to read and work with.

If you want the build to fail if name is null, then why in your example is name explicitly nullable? Perhaps the example could be clearer to show a compelling situation.

If its just about output format, you could write something like return `Name is: ${name || 'not supplied'}`;.

This is a contrived example; obviously I wouldn't write a function like this. In real work, we deal with complex data structures that have many fields of differing types. We process data that is far removed from its type definitions. That's the whole point of static typing.

I wouldn't write a function so narrowly purposed that allowed null, of course, but I might have a React component that takes a lot of props, some of which can be null or are optional, but I will still eventually use string templates to format data involving those fields. Unless I wrapped every single usage of a string template literal in a function that took a string arg for every field, I would face this risk.

The point of type safety is to protect you from mistakes. If people always correctly grokked the type of every variable they used in practice, then we wouldn't need typescript at all. But people don't. I might have a structure:

type Person = {
   firstName: string;
   lastName: string;
   middleName?: string; 
   address: Address;
}

If I did this:

const info = `${data.firstName} ${data.middleName} ${data.lastName} lives at ${data.address}`

I get

"Joe undefined Blow lives at [Object object]"

This would be a very easy mistake to make.

Personally, there's never a time when I want type coercion. I don't see why this is a place you'd want it, any more than you'd want it when assigning anything else to a variable of type string. String literal templates only deal with strings; it seems well within the purpose of TypeScript to enforce that we can only pass them strings.

BTW, the use case that this came up had to do with refactoring. I had to take an existing type and change it from string to string | null. If string template literal types were typesafe, the compiler would immediately fail in every place it was used in a string literal, as it currently would fail everywhere else it was no longer safe to use. Without the ability to type string template literals, you can't ever safely refactor code involving them when widening types.

Regarding this:

So `Step ${i} of ${count}` would have to be written as `Step ${i.toString()} of ${count.toString()}`?

I'd be fine with this - thought I'd probably say Step ${String(i)} of ${String(count)}

But I think it would also be reasonable to accept both number and string types, and perhaps boolean, since their string equivalent is reasonably well defined and generally purposeful.

But I would never want null, undefined, or object to be allowed without my explicitly coercing it. I can't think of any situations where I'd intentionally want to convert those types to a string through coercion by default.

I thinks this should be optional, as a compiler directive, like many other options that enforce stricter typing requirements.

I agree that implicit coercion of null, undefined, and object types to string is almost always an error in the code. But even then there are exceptions - such as when the object defines its own toString method, or when the template literal is tagged.

I also agree that there can be situations where you intentionally want to do that. But in practice, mistakes happen far more than those use cases.

I'd much rather say address.toString() if I really wanted that, then have no type checking for any string templates, just like I would have to anywhere else in my code that I wanted to convert address to a string.

Refactoring is really where it becomes a killer. When I went through the exercise today of having to manually inspect every bit of code that consumed the structure I'd refactored to see if it was involved in any templates, and also wondering if there were any derived usages I missed, it felt like a big hole.

The core problem here is that people add custom toString methods with "good" output with reasonable frequency. The "real" solution would be a way to mark either blessed or cursed toString methods so that e.g. we can detect that Object#toString is a bad call but MyCustomPointWithNiceToString#toString is a good call.

The usage of good toString() seems like it is in conflict with general good practice in typescript. You can't do this:

let x: string = '';
x = someObjectThatHasToString;

You'd have to say:

x = someObjectThatHasToString.toString()

I don't see why you'd want to treat string templates any differently as part of a comprehensive static typing system. This is the only place you're allowed to not explicitly call toString() in order to get the output of that method in typescript.

I don't really like "toString()" anyway as part of production code - while it's handy for console logging or debugging interactively, I wouldn't want to use it for real output. Overriding the prototype method provides engineers with no information about what's actually being output for the object. Anything that's not trivial almost certainly has more than one possible format in which it's output could appear, so I think it's much better to use expressive names for string formatting methods.

Again - I'd much rather be required to explicitly invoke a method when using an object in a string template. In my perfect TypeScript world there's no "good" toString

I certainly understand some people might feel differently, hence making it optional seems reasonable for this reason and backward compatibility. But at the same time I think the notion of being able to disable coercion for string templates has value and would benefit users who prefer better type safety over convenience. I can't think of any other exceptions to "no coercion" (other than using any) and as such it presents a risk that seems like it shouldn't be necessary when using string templates and refactoring code.

Here's something else interesting related to this. In designing classes, we'd actually like to forbid the use of string coercion. It seems like this should be possible:

class Foo {
  private value: string;
  constructor(value: string) {
    this.value = value;
  }
  toString(): never {
    throw new Error();
  }
  toNamedFormat(): string {
    return this.value;
  }
}

const foo = new Foo('abc');
const x = `foo is ${foo}`;

But TypeScript has no problem with this. Probably I just don't understand the totality of how never works. Is there any way to create a method with a return type that would be forbidden by string templates?

I ran into this with something like:

console.log(`Name is ${name}`);

Name was not defined in my scope, but in lib.dom.d.ts it was defined as:

declare const name: never;

So my code compiled but at runtime I got:

ReferenceError: name is not defined

Result: https://github.com/firebase/firebase-tools/pull/1241

We had a similiar issue. We have split shared code into libs, which are used in several services. Every lib is used in at least 2 services. Someone expanded an interface, replacing a string with an object.

In our code we used a string template like this Foo ${bar}, where bar changed to an object. So what I expected was, that the compiler throws an error. Instead we had an object Object in production. I think, if you have a custom toString method, it's okay to call it in this context instead of intransparently breaking code.

on node:

const moment = require( 'moment' );
console.info( `show: [${ moment.utc() }]` )

//output: 'show: [Wed Oct 30 2019 11:30:00 GMT+0000]'

on ts-node:

const moment = require( 'moment' );
console.info( `show: [${ moment.utc() }]` )

//output: 'show: [1572435290801]'

But If you use "target": "es2015" then you will have native template literals

how to change this behavior on other target?

I've also run into some frustration with this. Not only with template strings, but similarly with the + operator. 'hi ' + {} does not complain.

One possible solution would be to add a way to disable implicit coercion on individual types defined in TS (as opposed to native ones)? Just a thought.

Just hit this issue myself. It's very easy to start relying on the compiler to catch silly stuff, and miss something inane like forgetting to call a function; it even made it through review unnoticed.

In my case, our CI acceptance tests were failing with a very peculiar looking output... turns out the template literal was coercing a function, per the above.

If you've got the following:

const fn = (): string => 'a string';
const x = `/blah/blah/${fn()}`;

And you forget to call fn, instead interpolating ${fn}, the compiler really should flag it up. I'd posit it's far more likely to be accidental than not, and if someone really wants to toString their function/object for some reason - surely a very rare use case? - then they can always do so manually.

In terms of safety, flexibility, and being as helpful as possible to as many developers as possible, I think this warrants a fix.

Is there any workaround for this now?

I need this too

typescript-eslint has a restrict-template-expressions rule to catch this

This is also a problem for us where we have a lot of methods and getters for building template literals. If the arguments are omitted we get runtime errors. Following is my naive attempt:

const neverString = <T>(arg: T) => arg as T & { toString: never };

const f = neverString((x: number) => `${2 * x}`);

`${f(5)}`; // OK as expected
`${f.toString()}`; // Error as expected
`${String(f)}`; // OK - should be error?
`${f}`; // OK - should be error?

I'm aware that there's a countervailing expectation that String(...) should always be allowed however.

This seems to me like one of the weakest links in TypeScript's type system – in the weak–strong sense related to the presence and semantics of implicit type coercion (not static–dynamic, explicit–inferred, expressive–inexpressive or any other type-system-defining dimensions that are often confused with each other or used ambiguously).

What often happens for me in practice is this:

text.ts:

export default {
    url: `https://example.com`,
} as const;

main.ts:

import T from "./text";

console.log(`Your URL: ${T.url}`);

Then I refactor/add a new feature/whatever such that text.ts looks like this instead:

export default {
    url: (user: string) => "https://example.com/" + user,
} as const;

Or perhaps this:

export default {
    url: {
        external: "https://foo.bar",
        profile: "https://example.com/" + user,
    },
} as const;

main.ts now prints something like Your URL: user => "https://example.com/" + user or Your URL: [object Object] – and _the type checker doesn't help me to detect this at all_, even though it could have. Especially when looking at this from a Haskell perspective, I really expect a type error, as I'm used to being protected against implicit and/or nonsensical stringification in that language:

$ ghci
> "" ++ id

<interactive>:1:7: error:
    • Couldn't match expected type ‘[Char]’ with actual type ‘a0 -> a0’
    • Probable cause: ‘id’ is applied to too few arguments
      In the second argument of ‘(++)’, namely ‘id’
      In the expression: "" ++ id
      In an equation for ‘it’: it = "" ++ id

> "" ++ show id

<interactive>:2:7: error:
    • No instance for (Show (a0 -> a0)) arising from a use of ‘show’
        (maybe you haven't applied a function to enough arguments?)
    • In the second argument of ‘(++)’, namely ‘show id’
      In the expression: "" ++ show id
      In an equation for ‘it’: it = "" ++ show id

(GHC even points out the actual cause of the problem in these cases, namely that I haven't applied id.)

Was this page helpful?
0 / 5 - 0 ratings

Related issues

Zlatkovsky picture Zlatkovsky  Âˇ  3Comments

manekinekko picture manekinekko  Âˇ  3Comments

fwanicka picture fwanicka  Âˇ  3Comments

MartynasZilinskas picture MartynasZilinskas  Âˇ  3Comments

kyasbal-1994 picture kyasbal-1994  Âˇ  3Comments