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

[feature request] Support Custom Types for Tagged Template Expressions #16551

Open
ForbesLindesay opened this issue Jun 15, 2017 · 7 comments
Open
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@ForbesLindesay
Copy link
Contributor

Problem Statement

It is common to embed queries in tagged template expressions within typescript. For example, you might use sql:

const result = await querySQL(sql`SELECT * FROM users WHERE id = ${userID}`);

You also see a similar pattern with graphql:

const result = await queryGraph(graphql`query { user { id, name } }`);

Currently, there is no way to provide type information about what is returned by queryGraph/querySQL. A lot of potential type information is lost at the boundary here.

I obviously don't expect typescript to read the graphql or SQL schema, as that's way out of scope. What I would like is a way to provide this information as a module author.

Proposal

I believe that all that's needed is a way to write plugins that answer the question "given a tagged template expression, what type does it return". This would only affect the type checker. It would not change generated code or add any new syntax.

A new compilerConfig option called taggedTemplateHandlers would be added, that would take an array of paths to typescript files. An example might look something like:

import {parseSqlQuery, convertSqlTypeToTypeScriptType} from 'sql-helpers';
import * as ts_module from "typescript/lib/tsserverlibrary";

export default {
  tag: 'sql',
  templateHandler(modules: {typescript: typeof ts_module}, strings: string[], ...expressions: Array<ts_module.Expression>) {
    const ts = modules.typescript;

    const fields = parseSqlQuery(strings.join('"EXPRESSION"'));
    return ts.createTypeReferenceNode(
      ts.createIdentifier('SqlQuery'), // typeName
      ts.createNodeArray([ // typeArguments
        ts.createTypeLiteralNode(Object.keys(fields).map(field => {
          return ts.createPropertySignature(
            ts.createIdentifier(field),
            undefined, // question token
            convertSqlTypeToTypeScriptType(fields[field]), // type
            undefined, // initializer
          )
        }))
      ])
    );
  }
};

You could then define querySQL like:

define function querySQL<TResult>(query: SqlQuery<TResult>): Promise<TResult>;

Language Feature Checklist

  • Syntactic - no new changes
  • Semantic
    • When a tagged template is encountered by the type-checker
      1. See if a taggedTemplateHandlers has been registered for that tag
      2. Call that taggedTemplateHandler if one exists.
      3. Use the TypeNode returned by the taggedTemplateHandler in place of the default behaviour.
  • Emit - no new changes
  • Compatibility - no new syntax is added/changed, so it should be fully backwards/forwards compatible.
  • Other
    • I expect there will be some performance impact, but hopefully the fact that both plugins and the compiler are written in typescript should make this minimal.
    • Ideally, this information would be used for autocomplete helpers as well as in the typechecker, I do not know if that would require extra work.

I'm happy to do my best to help implement this, but I would need some pointers on where to start.

P.S. would it be possible to pass in the type of the expressions, in place of the actual expressions themselves?

@DanielRosenwasser DanielRosenwasser added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Jun 16, 2017
@DanielRosenwasser
Copy link
Member

This is very much related to #3136. To be frank, I don't know how ready we are to implement something like this. I think it'd be extremely useful, but it's a fairly large change that I'm not sure we could commit to any time in the near future, but I'd be open to hearing others' thoughts.

@ForbesLindesay
Copy link
Contributor Author

True, this is closely related to #3136, but I think it's a stronger use case. You can always use code generation to generate strongly typed APIs for remote services. The key difference here is that we would be allowing users to stick with the native language.

@jrylan
Copy link

jrylan commented Nov 30, 2017

Seems like this would be best suited to a Language Service Plugin. @mjbvz has been doing a lot of great work recently to provide intellisense and syntax highlighting for tagged template literals.

@mjbvz: Do you know of anyway using the current Language Service Plugin API to provide return type safety to tagged template literals? Or would it need to be a completely new feature added to the API?

I'm trying to piece together a much easier (and feature complete) VS Code extension + Language Service Plugin for GraphQL.

@mjbvz
Copy link
Contributor

mjbvz commented Dec 1, 2017

Not sure if a plugin can modify TypeScript's typing information. @RyanCavanaugh would know more about this.

One thing to keep in mind is that plugins are currently focused on enhancing the editor experience. I do not believe they are loaded when you run tsc

@jrylan
Copy link

jrylan commented Dec 1, 2017

Thanks for the reply @mjbvz -- my current approach is going in the direction of a VS Code plugin that interfaces with the language service to create type definitions and then insert references to them into the current document.

For example:
Foo.ts

const foo = graphql`
  query { ... }
`

The VS Code plugin figures out the type information for the given query, and then creates a new file with the type interface defined in a subdirectory of the original file, so in this case you end up with the following file structure:

Foo.ts
__generated__/Foo.queries.d.ts

The original file (Foo.ts) is modified via the VS Code plugin to add the relevant import and reference as such:

import { QueryResult } from './__generated__/Foo.queries'
const foo = <QueryResult>graphql`
  query { ... }
`

It's complicated, but the end result does work well for type safety. Hope to polish it all up and release soon.

@a-type
Copy link

a-type commented Jan 19, 2019

I'd like to revisit this. I've used the code generation solutions like @jayrylan mentions, but I find them to be a bit too complicated. It was something of an effort to set up, it's annoying to have to run a watcher process or install an editor plugin and try to figure out what went wrong if the typings are incorrect or fail to generate. The generators I've used also all rely on query and fragment names not colliding, since the generator dumps all of them into the same massive types file. I find it's a speed bump to educate new project contributors about what is going on (why import the query from here, but the typings from there) even if they understand TypeScript itself.

And at the core of it, it just feels a little... wrong... to have the promises of typing from TypeScript, and a strongly typed API from GraphQL, but they just don't work together without a bunch of duct tape.

Before finding this issue to tag along to, my original idea for a solution was to invent a new keyword for types which are dynamically generated from generics.

generatedType GraphQLResult<TDoc extends GraphQLDocument> = (doc: TDoc) => {
  const ast = parseGraphQLDocument(doc);
  return walkASTAndConvertToTypeScriptTypes(ast);
};

function graphQLRequest<TDoc extends GraphQLDocument>(doc: TDoc): GraphQLResult<TDoc> {
  // ...
}

Obviously there's a lot of difficulties there. Presumably TypeScript would remove the generatedType definition from the code during processing. If that code references other functions (as mine does), not sure how it would know whether to remove those too. I also included a reference to a parsing function which would probably be from a library. I don't pretend to know how TypeScript works or if it would be able to import that function from that library during type checking in order to resolve this type.

However, I do like the approach in general, because the generated type could be bundled up into a library (like one that makes GraphQL requests) and the user would just get full typing without even having to think about it.

I also think the concept may be useful outside of just templates. For instance, typing this function:

function camelCaseKeys<T extends {}>(t: T): CamelCased<T> { /* ... */ }

camelCaseKeys({ A: 0, FooBar: 1 });
// { a: 0, fooBar: 1 }

I get that this feature would probably be massively difficult, probably even more so than I'm aware. Perhaps not even possible with the way TS works today. But I could see it also being a huge step forward in seamlessly typing more things.

@maxpain
Copy link

maxpain commented Jun 15, 2021

Any news?

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
Development

No branches or pull requests

6 participants