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

Template literal types and mapped type 'as' clauses #40336

Merged
merged 35 commits into from
Sep 10, 2020
Merged

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Aug 31, 2020

This PR implements two new features:

  • Template literal types, which are a form of string literals with embedded generic placeholders that can be substituted with actual string literals through type instantiation, and
  • Mapped type as clauses, which provide the ability to transform property names in mapped types.

Template literal types

Template literal types are the type space equivalent of template literal expressions. Similar to template literal expressions, template literal types are enclosed in backtick delimiters and can contain placeholders of the form ${T}, where T is a type that is assignable to string, number, boolean, or bigint. Template literal types provide the ability to concatenate literal strings, convert literals of non-string primitive types to their string representation, and change the capitalization or casing of string literals. Furthermore, through type inference, template literal types provide a simple form of string pattern matching and decomposition.

Template literal types are resolved as follows:

  • Union types in placeholders are distributed over the template literal type. For example `[${A|B|C}]` resolves to `[${A}]` | `[${B}]` | `[${C}]`. Union types in multiple placeholders resolve to the cross product. For example `[${A|B},${C|D}]` resolves to `[${A},${C}]` | `[${A},${D}]` | `[${B},${C}]` | `[${B},${D}]`.
  • String, number, boolean, and bigint literal types in placeholders cause the placeholder to be replaced with the string representation of the literal type. For example `[${'abc'}]` resolves to `[abc]` and `[${42}]` resolves to `[42]`.
  • Any one of the types any, string, number, boolean, or bigint in a placeholder causes the template literal to resolve to type string.
  • The type never type in a placeholder causes the template literal to resolve to never.

Some examples:

type EventName<T extends string> = `${T}Changed`;
type Concat<S1 extends string, S2 extends string> = `${S1}${S2}`;
type ToString<T extends string | number | boolean | bigint> = `${T}`;
type T0 = EventName<'foo'>;  // 'fooChanged'
type T1 = EventName<'foo' | 'bar' | 'baz'>;  // 'fooChanged' | 'barChanged' | 'bazChanged'
type T2 = Concat<'Hello', 'World'>;  // 'HelloWorld'
type T3 = `${'top' | 'bottom'}-${'left' | 'right'}`;  // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
type T4 = ToString<'abc' | 42 | true | -1234n>;  // 'abc' | '42' | 'true' | '-1234'

Beware that the cross product distribution of union types can quickly escalate into very large and costly types. Also note that union types are limited to less than 100,000 constituents, and the following will cause an error:

type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Zip = `${Digit}${Digit}${Digit}${Digit}${Digit}`;  // Error

A template literal placeholder may optionally specify an uppercase, lowercase, capitalize, or uncapitalize modifier before the type. This modifier changes the casing of the entire replacement string or the first character of the replacement string. For example:

EDIT: Based on feedback, the casing modifiers have been replaced by intrinsic string types in #40580.

type GetterName<T extends string> = `get${Capitalize<T>}`;
type Cases<T extends string> = `${Uppercase<T>} ${Lowercase<T>} ${Capitalize<T>} ${Uncapitalize<T>}`;
type T10 = GetterName<'foo'>;  // 'getFoo'
type T11 = Cases<'bar'>;  // 'BAR bar Bar bar'
type T12 = Cases<'BAR'>;  // 'BAR bar BAR bAR'

Template literal types are all assignable to and subtypes of string. Furthermore, a template literal type `${T}` is assignable to and a subtype of a template literal type `${C}`, where C is a string literal type constraint of T. For example:

function test<T extends 'foo' | 'bar'>(name: `get${Capitalize<T>}`) {
    let s1: string = name;
    let s2: 'getFoo' | 'getBar' = name;
}

Type inference supports inferring from a string literal type to a template literal type. For inference to succeed the starting and ending literal character spans (if any) of the target must exactly match the starting and ending spans of the source. Inference proceeds by matching each placeholder to a substring in the source from left to right: A placeholder followed by a literal character span is matched by inferring zero or more characters from the source until the first occurrence of that literal character span in the source. A placeholder immediately followed by another placeholder is matched by inferring a single character from the source.

Some examples:

type MatchPair<S extends string> = S extends `[${infer A},${infer B}]` ? [A, B] : unknown;

type T20 = MatchPair<'[1,2]'>;  // ['1', '2']
type T21 = MatchPair<'[foo,bar]'>;  // ['foo', 'bar']
type T22 = MatchPair<' [1,2]'>;  // unknown
type T23 = MatchPair<'[123]'>;  // unknown
type T24 = MatchPair<'[1,2,3,4]'>;  // ['1', '2,3,4']

type FirstTwoAndRest<S extends string> = S extends `${infer A}${infer B}${infer R}` ? [`${A}${B}`, R] : unknown;

type T25 = FirstTwoAndRest<'abcde'>;  // ['ab', 'cde']
type T26 = FirstTwoAndRest<'ab'>;  // ['ab', '']
type T27 = FirstTwoAndRest<'a'>;  // unknown

Template literal types can be combined with recursive conditional types to write Join and Split types that iterate over repeated patterns.

type Join<T extends unknown[], D extends string> =
    T extends [] ? '' :
    T extends [string | number | boolean | bigint] ? `${T[0]}` :
    T extends [string | number | boolean | bigint, ...infer U] ? `${T[0]}${D}${Join<U, D>}` :
    string;
type T30 = Join<[1, 2, 3, 4], '.'>;  // '1.2.3.4'
type T31 = Join<['foo', 'bar', 'baz'], '-'>;  // 'foo-bar-baz'
type T32 = Join<[], '.'>;  // ''
type Split<S extends string, D extends string> =
    string extends S ? string[] :
    S extends '' ? [] :
    S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] :
    [S];

type T40 = Split<'foo', '.'>;  // ['foo']
type T41 = Split<'foo.bar.baz', '.'>;  // ['foo', 'bar', 'baz']
type T42 = Split<'foo.bar', ''>;  // ['f', 'o', 'o', '.', 'b', 'a', 'r']
type T43 = Split<any, '.'>;  // string[]

The recursive inference capabilities can for example be used to strongly type functions that access properties using "dotted paths", and pattern that is sometimes used in JavaScript frameworks.

type PropType<T, Path extends string> =
    string extends Path ? unknown :
    Path extends keyof T ? T[Path] :
    Path extends `${infer K}.${infer R}` ? K extends keyof T ? PropType<T[K], R> : unknown :
    unknown;

declare function getPropValue<T, P extends string>(obj: T, path: P): PropType<T, P>;
declare const s: string;

const obj = { a: { b: {c: 42, d: 'hello' }}};
getPropValue(obj, 'a');  // { b: {c: number, d: string } }
getPropValue(obj, 'a.b');  // {c: number, d: string }
getPropValue(obj, 'a.b.d');  // string
getPropValue(obj, 'a.b.x');  // unknown
getPropValue(obj, s);  // unknown

Mapped type as clauses

With this PR, mapped types support an optional as clause through which a transformation of the generated property names can be specified:

{ [P in K as N]: X }

where N must be a type that is assignable to string | number | symbol. Typically, N is a type that transforms P, such as a template literal type that uses P in a placeholder. For example:

type Getters<T> = { [P in keyof T & string as `get${Capitalize<P>}`]: () => T[P] };
type T50 = Getters<{ foo: string, bar: number }>;  // { getFoo: () => string, getBar: () => number }

Above, a keyof T & string intersection is required because keyof T could contain symbol types that cannot be transformed using template literal types.

When the type specified in an as clause resolves to never, no property is generated for that key. Thus, an as clause can be used as a filter:

type Methods<T> = { [P in keyof T as T[P] extends Function ? P : never]: T[P] };
type T60 = Methods<{ foo(): number, bar: boolean }>;  // { foo(): number }

When the type specified in an as clause resolves to a union of literal types, multiple properties with the same type are generated:

type DoubleProp<T> = { [P in keyof T & string as `${P}1` | `${P}2`]: T[P] }
type T70 = DoubleProp<{ a: string, b: number }>;  // { a1: string, a2: string, b1: number, b2: number }

Fixes #12754.


Playground: https://www.typescriptlang.org/play?ts=4.1.0-pr-40336-88

@typescript-bot typescript-bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Aug 31, 2020
@ahejlsberg ahejlsberg added this to the TypeScript 4.1.0 milestone Aug 31, 2020
@alii
Copy link

alii commented Aug 31, 2020

Looks incredible! Nice work.

@taxilian
Copy link

very cool, thanks for doing this! It will be tricky to use this well without exceeding the "50 steps" limit with libraries like mongoose, but still will enable a lot of great things

@bschlenk
Copy link

This looks really good! Will there be a way to add more modifiers? The times I’ve wanted this feature it’s been to convert from ALL_CAPS to camelCase.

@calebeby
Copy link

@bschlenk It seems like that should be implementable by users since split/join can be implemented as shown in the PR description

@calebeby
Copy link

Can we get a playground for this PR?

Not sure if this will work since I'm not a part of the TS team:

@typescript-bot pack this

@orta
Copy link
Contributor

orta commented Aug 31, 2020

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Aug 31, 2020

Heya @orta, I've started to run the tarball bundle task on this PR at c95c000. You can monitor the build here.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Aug 31, 2020

Hey @orta, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/83806/artifacts?artifactName=tgz&fileId=AFC60CCD5AEE4CC19D6C15D9044564BC4E2AEA5FE84FC89B8DB3C2386C10DB0202&fileName=/typescript-4.1.0-insiders.20200831.tgz"
    }
}

and then running npm install.


There is also a playground for this build.

@danvk
Copy link
Contributor

danvk commented Sep 1, 2020

Is it possible to split a string literal type into a tuple of its characters? If you could split "foo" into ["f", "oo"] then you wouldn't need to special case capitalize and uncapitalize. (A typed camelCase function has always been my go-to example of something that's too complex for TS to type, but it seems very, very close!)

@rickbutton
Copy link

@danvk converting between camelCase, snake-case, PascalCase etc is already possible with this PR: here is a playground link

@danvk
Copy link
Contributor

danvk commented Sep 1, 2020

Thanks @rickbutton! I'm wondering if it's possible to go the other way, though: FooBarfoo-bar or foo_bar.

@g-plane
Copy link
Contributor

g-plane commented Sep 1, 2020

For the mapped type as clauses, the current behavior of compiler is:

type DoubleProp<T> = { [P in keyof T & string as `${P}1` | `${P}2`]: T[P] }
type T70 = DoubleProp<{ a: string, b: number }>

type Keys = keyof T70  // ==> 'a' | 'b'

Is this intended? Why isn't 'a1' | 'a2' | 'b1' | 'b2'?

@fc01
Copy link

fc01 commented Sep 17, 2021

@vituchon
Copy link

Dear devs... I see this an scream a little, allow me to explain. You have done a titanic job, that doesn't mean that I agree with everthing.

Could you give a real life example of someone taking leverage of this "Template Literal Types" for solving a given problem?

I Mean, what where the motivations to complex the language like this in my proyect? What is the benefit?...

Looks like you are mixing types and values in a chaotic way. I have never seen something like type Pepe<'Caca'> the diamond operator <> I know expect a type, not a literal value that is not a type.

So let's try this... you may convince me...

A thing like this

1️⃣

type EventName<T extends string> = `${T}Changed`;

Could be written like this...

2️⃣

function buildChangeEventName<T extends string>(eventName: T) {
  return `${eventName} changed`
}

And the second choise is more "familiar" that the first one.

I'm all eyes!

@RyanCavanaugh
Copy link
Member

@vituchon the linked issue in the PR, #12754, shows dozens of examples of people needing to do string manipulation in the type system, and had hundreds of upvotes (which at the time would have made it one of the top 10 or so most-upvoted suggestion at the time). There are also dozens of inbound duplicate links to #12574 outlining other use cases.

@vituchon
Copy link

vituchon commented Feb 28, 2023

Ok thanks @RyanCavanaugh for pointing out some material. It will take some time to analize and understand, probably later (without rush) I can answer properly.

For others that shows an 👎 allow me to teel that gesture doesn't show/tell/say me nothing. Allow me to explain the feeling: I do not need desapprobal neither approbal, I need to comunicate and understand things. I just wrote an example, in the meantime while I study the new material, I'm open to see what is wrong with that.

@jcalz
Copy link
Contributor

jcalz commented Feb 28, 2023

My 👎 was intended to convey that this issue really isn't a proper forum for the kind of discussion you're trying to have, without necessarily adding to the noise by leaving my own comment. I guess I failed at both pieces of that.

I'd suggest you read the TypeScript Handbook sections on literal types and template literal types and the release notes for template literal types and then, if you still want to discuss this, go to an appropriate forum like the TypeScript Community Discord server.

@vituchon
Copy link

vituchon commented Feb 28, 2023

Jcalz, as far I know, the tab title says "conversation", and that is what we are having. Thanks for the material. If you don't want to recibe my feedback, you may ignore it.

Recall if you don't express with words (or just use emogis) and don't try to talk to another person (of a different country with different culture) you barely (and unlike) will reach goals of getting understand

@RyanCavanaugh
Copy link
Member

Jumping into a nearly three-year-old feature and aggressively questioning the motivation of why it was added to the language is very unlikely to result in a useful dialog, and is indeed not the intended use of this forum. Getting a 👎 reaction for that is frankly the absolute minimum amount of pushback you should expect. Picking fights with other users (or the maintainers for that matter) is not acceptable behavior and you can consider yourself warned on that count.

@vituchon
Copy link

vituchon commented Feb 28, 2023

Well, I have no intention to be aggresive... at all.

In any case, if stating "I see this an(d) scream a little" makes you fell bad, you may forgive me. Leaving that aside I guess I was polite... I really don't see any trail of aggression. You may highlight my aggresive statements and I will take that into account.

And allow to me repeat...

My intention was to see someone to answer my code with motivations, I mean ¿why I should use a (complex) type if a generic function already solves the problem? You may show me what I'm missing, if that is no too much to ask.

@RyanCavanaugh
Copy link
Member

if a generic function already solves the problem

Well, it doesn't. Your function in value space doesn't do anything to address string transforms in type space.

@vituchon
Copy link

Thanks for the answer. I don't get it too well. I'm missing some key points and don't want to bother anymore.
I will continue to read more.

Until then!

Greetings

Víctor.

@Alpvax
Copy link

Alpvax commented Feb 28, 2023

To use your example to explain why it exists, try to add the specific return type to your function (not just string)

@vituchon
Copy link

vituchon commented Feb 28, 2023

Thanks @Alpvax... I appreaciate your intention, you mean adding a Formal type parameter in the return type, ¿like this? , well that is something I have never thought about (but I do recall playing in Java with covariant return types). I'm trying to get it, I do need more time and further reading.

TL;DR;

I have faced complexity a lot of times and I know I will keep facing it so many more. At the end of the day I do feel that the "battle" is reduced to find the right set of abstractions (for solving a given problem) and the language in which you code will help more or will help less writing effectively those abstractions (by abstraction I mean types: classes, interfaces, ADT or any other declaration or definition of data structure) . In typescript I do have my technical debts, for example: writing a generic abstraction for dealing with persistence of a given data Type that acts as an Entity ("Entity" in DDD terms), like a base abstract class that servers for writing a template DAO.

I try to understand a scenario where this "new" types (at least, for me) cames to be in handy, I also know that I need to read more. Working everyday gives me little to read, but I will continue struggeling until get some XP (experience points) off this.

@Alpvax
Copy link

Alpvax commented Mar 1, 2023

you mean adding a Formal type parameter in the return type, ¿like this?

Yes, but that type is not correct. If the input is T, the output is not. The output is T + " changed". That is where the template literal types come in.
A more useful example would be processing an object, where the input is keyof T. In order to keep the key names and not just return a type of Object, you need the template types.
Either way, this isn't the place to continue this discussion, but I hope I have helped clarify it for you.

@vituchon
Copy link

vituchon commented Mar 1, 2023

Thanks @Alpvax! You provide me valuable insight. I see now that the thing is about values that follow a pattern....

In the chronological continuum (I mean, from here to some time in the future) I hope to see and chat with all or anyone of you in the TypeScript Community Discord server. I understand that is the place to continue "long conversations".

Thanks again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

Augment Key during Type Mapping