microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.3k stars 12.39k forks source link

typescript checking frontend template files #5151

Open ducin opened 8 years ago

ducin commented 8 years ago

The greatest feature of TypeScript, in terms of big enterprise apps, is type checking. When modifying/extending something low-level, I'll get all errors that arise beneath - just like I would in Java and .Net. But this relates to TS code.

My question is: is it possible to use TypeScript to check types used in frontend templating engines, such as Handlebars, Underscore or [your favourite templating engine]? I've read that TypeScript 1.6 is gonna support react's JSX, but it's a big limitation to one specific engne only. I guess there is no built-in platform-supported solution, but maybe there is someone who made it work somehow?

Or, in other words, what would you do if you wanted to use TypeScript to check types of variables in handlebars/underscore templates?

mhegazy commented 8 years ago

This is an interesting topic, but I do not have any ideas here. the main problem i see, is that the html template and the JS code that populates its context are detached, and the linkage happens dynamically. JSX on the other hand does not have this issue, as the JSX elements are evaluated in the context of their declaration.

Would love to discuss ideas here on how to we can get the type checking in the html templates..

One option is to get the template compilers to generate .ts code instead of .js code and then the compiler can typecheck it, we could also use something akin of sourcemaps to make sure that errors are reported in your html template instead of in the generated .ts code. this leaves us with the tooling, like suggestions while you are writing your html template, and i think the IDE's can use naming conventions to identify the context object you are using and then provide completions, goto definition, etc..

ducin commented 8 years ago

Considering the easiest example, underscore's _.template function:

var compiled = _.template("hello: <%= name %>");
compiled({name: 'moe'});
=> "hello: moe"

we should be able to decorate/extend the base function, so its contract remains the same (behaves the same way from the outside).

What we want here is to make typescript compiler throw errors, when non-existent variable is accessed in the template, right? In order to do so, one option mentioned is to generate typescript code from template files. This generated code would have to rely on strict types (other than any) to run typechecking.

I did a quick check, that typescript's typecheck doesn't rely on inheritance, but on field names:

class A {
    field1: string;
}

class B {
    field1: string;
}

class C {
    field2: string;
}

function hello(a: A) {
    return a.field1;
}

var a = new A();
var b = new B();
var c = new C();

console.log( hello(a) ); // passes, obvious
console.log( hello(b) ); // passes, although B and A are not related (!), no "extends", no "implements"
console.log( hello(c) ); // fails, because of missing property, obvious

This means that we could parse a template, analyse what variables/structures are used there, generate disposable interfaces with optional properties (one for each template) and use them to typecheck parameters of the compiled template. For example, having following underscore template:

var compiled = _.template("hello: <%= name %>, we've got <%= weather %> weather today!");

we could generate the disposable interface:

interface template_Gl0oerMwXZ {
    name?: string;
    weather?: string;
}

and use it as the type of the only input parameter of the function:

var compiled = function(templateVariables: template_Gl0oerMwXZ) {
    // do something
}

Then we decorate the template with real data. The correct invocation:

compiled({name: "moe", weather: "beautiful"});

passes, while the typo one (note weathar instead of weather):

compiled({name: "moe", weathar: "beautiful"});

fails with following error: Argument of type '{ name: string; weathar: string; }' is not assignable to parameter of type 'template_Gl0oerMwXZ'. Object literal may only specify known properties, and 'weathar' does not exist in type 'template_Gl0oerMwXZ'. The error tells exactly what is wrong, but it might also tell which file does the template come from.

I think that's what we need, in general. This idea has some unsolved problems, such as how to re-use external template compiling functions (underscore, handlebars, etc) beneath, but above proof of concept works as expected in the typescript console.


I didn't get the sourcemaps idea in that context (however I do know source maps in general). Does this solution require to extend typescript core (e.g. interpreting those source maps)?


Hope we can figure it out somehow, this would be a killer feature!

ducin commented 8 years ago

How to wrap the original _.template function with typechecking? I came up with following idea of a preprocessor... Each time typescript compiler encounters a template (be it a file or just inline template string), it scans it and produces following (or similar) typescript additional code:

interface template_Gl0oerMwXZ {
    name?: string;
    weather?: string;
}

function compiled_Gl0oerMwXZ(templateCode: string) {
    var compiled = _.template(templateCode);
    return function decorator_Gl0oerMwXZ(templateVariables: template_Gl0oerMwXZ) {
        return compiled(templateVariables);
    }
}

This code is linked to the original TS code. And for each occurence of _.template in original ts code, such as a backbone view (link code slightly modified):

  var SearchView = Backbone.View.extend({
    initialize: function(){
      this.render();
    },
    render: function(){
      // Compile the template using underscore
      var template = _.template( $("#search_template").html() );
      // Load the compiled HTML into the Backbone "el"
      this.$el.html( template( {abc: "def"}) );
    }

the preprocessor would replace:

var template = _.template( $("#search_template").html() );

with

var template = compiled_Gl0oerMwXZ( $("#search_template").html() );

The code would look like this:

  var SearchView = Backbone.View.extend({
    initialize: function(){
      this.render();
    },
    render: function(){
      // Compile the template using underscore
      var template = compiled_Gl0oerMwXZ( $("#search_template").html() );
// NOTE: template is decorator_Gl0oerMwXZ with template code enclosed in the closure
      // Load the compiled HTML into the Backbone "el"
      this.$el.html( template( {abc: "def"}) );
    }

Note that the closure function decorator_Gl0oerMwXZ would work out of the box.

So, for above example code, the preprocessor would generate:

interface template_Gl0oerMwXZ {
    name?: string;
    weather?: string;
}

function compiled_Gl0oerMwXZ(templateCode: string) {
    var compiled = _.template(templateCode);
    return function decorator_Gl0oerMwXZ(templateVariables: template_Gl0oerMwXZ) {
        return compiled(templateVariables);
    }
}

var SearchView = Backbone.View.extend({
    initialize: function () {
        this.render();
    },
    render: function () {
        // Compile the template using underscore
        var template = compiled_Gl0oerMwXZ( $("#search_template").html() );
        // Load the compiled HTML into the Backbone "el"
        this.$el.html(template( {abc: "def"} ));
    } });

and this would be now executed under standard typescript compiler, which would catch all errors.

Anyway, I'm trying to find an easier way...

ducin commented 8 years ago

For Handlebars:

var data = {
    users: [ { 
        person: {
            firstName: "Garry", 
            lastName: "Finch"
        },
        jobTitle: "Front End Technical Lead",
        twitter: "gazraa" 
    }, {
        person: {
            firstName: "Garry", 
            lastName: "Finch"
        }, 
        jobTitle: "Photographer",
        twitter: "photobasics"
    }, {
        person: {
            firstName: "Garry", 
            lastName: "Finch"
        }, 
        jobTitle: "LEGO Geek",
        twitter: "minifigures"
    } ]
}; 

Handlebars.registerHelper('fullName', function(person) {
  return person.firstName + " " + person.lastName;
});

$('body').append(template(data));

it could work exactly the same way (just with more advanced data structures, but it all seems parsable):

interface person_Gl0oerMwXZ {
    firstName?: string;
    lastName?: string;
}

interface usersElement_Gl0oerMwXZ {
    person?: person_Gl0oerMwXZ;
    jobTitle?: string;
    twitter?: string;
}

interface users_Gl0oerMwXZ {
    [i : number] : usersElement_Gl0oerMwXZ;
}

interface template_Gl0oerMwXZ {
    users?: users_Gl0oerMwXZ;
}

function compiled_Gl0oerMwXZ(templateCode: string) {
    var compiled = Handlebars.compile(templateCode);
    return function decorator_Gl0oerMwXZ(templateVariables: template_Gl0oerMwXZ) {
        return compiled(templateVariables);
    }
}

var source = $("#some-template").html(); 
var template = compiled_Gl0oerMwXZ(source); 
var data = {...};
$('body').append(template(data));

in above example this:

var template = Handlebars.compile(source);

has been replaced with:

var template = compiled_Gl0oerMwXZ(source);

Different templating engines

...just the preprocessor would need a different analyser for underscore and handlebars syntax. AngularJS templates seem more complicated, though...

Strict template properties

Handlebars' if syntax could determine whether auto-generated interfaces do contain ? for optional properties or not:

<div class="entry">
  {{#if author}}
    <h1>{{firstName}} {{lastName}}</h1>
  {{/if}}
</div>

in above case, this would generate optional author?: string; property. Without the if clause, author could be a required property. Or, for compatibility reasons, there could be a strict template mode or some other flags that would determine the ? presence in interfaces.

Above is handlebars's if syntax. Other syntax constructions could work the same way, just as each:

<ul class="people_list">
  {{#each people}}
    <li>{{this}}</li>
  {{/each}}
</ul>

that would determine, that a structure is a collection (just as users_Gl0oerMwXZ and usersElement_Gl0oerMwXZ above).

Template base type: String and/or Number

All above examples rely on strings. But numbers are valid atomic types of templating engines and should be supported by ts-based templates. And typescript compiler differentiates strings and numbers. Union types is a possible solution. Below code compiles with no errors:

interface person_Gl0oerMwXZ {
    firstName?: string|number;
    lastName?: string|number;
}

var p1: person_Gl0oerMwXZ = {
    firstName: "Tomasz",
    lastName: 123
} 

Since JavaScript doesn't check types, the original underscore/handlebars/whatever template code doesn't contain information about the type. So, probably, we'd need to state string|number; everywhere.

mhegazy commented 8 years ago

i think the inlined templates are different from template files. i would say the inlined templates resolution can be solved by #3136.

the standalone templates are different though. i do not think you want to infer types from them. you want to report errors there. e.g.

<div class="entry">
    <h1>{{lastname}}</h1>
</div>

i want to get an error that Class Entry does not have a property "lastname" cause i misspelled it, and it should have been "lastName" with a capital 'N'.

I think this is more useful than saying that this template expects a type with lastname and you passed it a type with lastName when the user instantiates it.

one other thing to consider, is you want tooling as well. in your VSCode/VS/Sublime/Eclipse/Webstorm/etc... you want to hit "goto def" on lastName and it takes you to your Entry class definition, or when you ask for "all references" to find this one as well, this way you can rename safely.

That is why i was saying the template compiler generates .ts code, with type annotations, that is "linked" to the template code some how, and then the TS compiler/Langage Service understands it as part of your program.

ducin commented 8 years ago

Regarding the Class Entry does not have a property "lastname" and the example code you pasted:

<div class="entry">
    <h1>{{lastname}}</h1>
</div>

I'm not sure if you want to enforce developers to use CSS class property that way? At least I can see no other place where Entry class is mentioned.

That is why i was saying the template compiler generates .ts code, with type annotations, that is "linked" to the template code some how, and then the TS compiler/Langage Service understands it as part of your program.

I was thinking about generating .ts code as well, basing on template code, that would specify the types. I'm afraid I know too little about TS compiler internals.

Let me know if I can help you further.

mhegazy commented 8 years ago

Sorry my bad. I did not mean the css class, that was just a copy/past from your example. I meant whatever "contex" object this template will be bound to at runtime, the one expected to have a property lastName. One issue is to know what template file binds to at runtime, and I do not have much knowledge here...

ducin commented 8 years ago

Do you mean that, in order to suggest a solution, we need to divide all frontend templating engines into two groups:

  1. compiled in compile-time
  2. compiled in runtime ? If not, please, clarify, how can I help.
mhegazy commented 8 years ago

I do not think I have a solution :) I was just brain storming. I do agree that there is a need for better experience here. If you would like to champion this issue and drive a proposal I would be willing to help.

I think there is error reporting, either in the template as I mentioned or at the use side as you suggested; and the tooling support.

styfle commented 8 years ago

@ducin @mhegazy I have been thinking about this problem since our team started getting bugs when refactoring and forgetting to change the handlebars templates. It's the one place in our code that is not type safe.

I implemented my own solution: typed-tmpl. Let me know what you think!

thorn0 commented 8 years ago

I've tried coming up with a solution for Angular 1.x templates based on JSX. Please have a look at my experiments.

It looked really promising in the beginning, but unfortunately, even though the type-checking for JSX in TypeScript is really flexible, it turned out to be not flexible enough for Angular.

See the first entry in the Issues section of README. If we have a look at an Angular component, we'll see that the attributes are bound directly to its properties, not to a separate props object. At first, I thought it worked great when I wrote an empty declaration for the ElementAttributesProperty interface:

interface ElementAttributesProperty {} 

Yes, TypeScript started to understand that the attributes correspond to the properties of the component class. But then I discovered that the compiler wants to see all the class members as attributes, even methods, private properties, etc. If not all the members are represented as attributes, it generates an error. And there is no way to make the compiler require that only some properties be represented as attributes, not all.

A demo: demo

hegdeashwin commented 7 years ago

@mhegazy & @ducin :

I am new to TypeScript and loved it. Currently, using Webpack + TypeScript + Handlebars and getting below error message:

ERROR in ./src/apps/router/routes.ts
(6,26): error TS2307: Cannot find module '../templates/homeTpl.handlebars'.

ERROR in ./src/apps/router/routes.ts
(9,27): error TS2307: Cannot find module '../templates/aboutTpl.handlebars'.

I have installed "@types/handlebars": "^4.0.31", and "typescript": "^2.0.3" in package.json and "handlebars": "registry:dt/handlebars#4.0.5+20160804082238", in typings.json

package.json

...
"dependencies": {
   ...
    "@types/handlebars": "^4.0.31",
    "handlebars": "^3.0.3",
    "typescript": "^2.0.3"
  ...
  },
  "devDependencies": {,
    ...
    "handlebars-loader": "^1.4.0",
    "ts-loader": "^0.9.0",
    "webpack": "^1.13.2"
...

tsconfig.json

{
  "compileOnSave": true,
  "compilerOptions": {
    "outDir": "./dist/",
    "sourceMap": true,
    "noImplicitAny": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "target": "es5",
    "removeComments": true
  },
"include": [
    "src/**/*"
  ],
"exclude": [
    "node_modules"
  ]
}

also my webpack.config.js includes

...

  resolve: {
    // Add '.ts' and '.tsx' as resolvable extensions.
    extensions: ["", ".ts", ".js"]
  },

  module: {
    loaders: [{
      test: /\.ts?$/,
      exclude: /node_modules/,
      loader: 'ts-loader'
    }, {
      test: /\.handlebars?$/i,
      loader: 'handlebars-loader'
    }]
  }
...

I tried a lot but not able to figure out what is going wrong! my repository https://github.com/Protocore-UI/protocore-typescript-edition.

Well, when I am excluding TypeScript and use ES6 with Webpack its working fine https://github.com/Protocore-UI/protocore-webpack-edition

Is there any way to tell TypeScript to ignore my handlebars import, so that Webpack "handlebars-loader" can manage my handlebars templates.

mhegazy commented 7 years ago

@hegdeashwin, the error is there because the compiler did not find a typescript file (.ts or .d.ts) to declare the shape of the module ../templates/homeTpl.handlebars. so what is needed is to inform the compiler of the shape.

The simplest way is to say all *.handlebars files are modules that return a string. e.g.:

// declarations.d.ts
declare module "*.handlebars" {
    const _: string;
    export default _;
}

or just:

// declarations.d.ts
declare module "*.handlebars";  // returns any

see https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#wildcard-character-in-module-names for more details.

hegdeashwin commented 7 years ago

Thank you @mhegazy that helped a lot, I added the declaration and gave the reference to declaration file in tsconfig.json file in files: [ "src/apps/templates/declarations.d.ts"] and the error is no more getting triggered but the HomeTemplate is undefined for both string and any type. Thus, my template is not getting rendered.

I fixed the issue by just adding require declaration :

declare function require(module: string): any;

let HomeTemplate = require('../templates/homeTpl.handlebars');

Removed "*.handlebars" module and its worked instead of undefined. Released my first TypeScript + Webpack + jQuery boilerplate https://github.com/Protocore-UI/protocore-typescript-edition, Once again thank you for your help

digaobarbosa commented 7 years ago

I find interesting the approach taken by this framework.http://karaframework.com/docs/views.html

It probably limits the usage in Html, but I would be much more confident of my templates with it.

But that would be another template library, not using the ones we already have.

emmanueltouzery commented 7 years ago

I wrote a project to handle this issue specifically for angular1 views. It uses the typescript compiler API to parse the javascript and typescript code, htmlparser2 to parse the views, and the parsimmon parser combinator library to parse angular expressions (such as "for .. in .. track by ..").

It then generates "viewtest" files, combining code from views and controllers, which then get type checked by the compiler at the same time as normal typescript files. It also allows the user to specify custom angular directives & filters to have them also type-checked.

Obviously it does not support everything possible with angular1, by far, as the scope is huge, but we use it on a real-size project at work, and tested it on another real-size one too.

You can find the project there: https://github.com/emmanueltouzery/ng-typeview

DaveWM commented 7 years ago

I've also written a tool with a similar aim to @emmanueltouzery's. However, it's a command line app, unlike ng-typeview which is a library. If anyone's interested it's here: https://github.com/DaveWM/attyc

ghost commented 7 years ago

That's is an interesting topic. That would be useful for angularjs 1.x templates separated files, and that should not be problematic because we can extend the compiler to do so:

HomeCtrl.js

export default class HomeCtrl {
    public myName:string = "Islam";
}

HomeView.html

<!-- vm as @HomeCtrl --> // That's the key
<div>
   <span ng-bind="vm.myName"></span>
</div>

Is the TypeScript team is willing to give this a priority? Because I know also an Angular 2+ teams that yet prefer to not inline the templates because of the "separation of concerns". So this problem is giving me and others to go with JSX where the most of us is still prefer the external templates files.

Huge Thanks for your great work

jbwyme commented 5 years ago

+1 on this feature. I would love to be able to type-check our pug templates. Even if we had to manually specify the "state" type manually through a comment in the template file, it would be a large improvement vs no type checking at all.