Marius Gundersen

Template Literal Types

With the release of TypeScript 4.1 we get a really powerful feature called template literal types. This is based on the template literals that are in JavaScript, but now for the type system in TypeScript. Together with conditional types it makes it possible to parse strings and represent them as types at compile time. An early example that I saw was of ts-sql, which is an sql database implemented purely in TypeScript types. This might seem like a silly example, and while clever not really useful. But I found a place where I needed this kind of functionality, and set about trying to understand implement template literal types.

WebGL and shaders

The scenario I wanted to explore is that of WebGL shaders, specifically the uniforms in the shaders. If you don't have much experience with WebGL (or GL shaders in general), then what you need to know is that working with WebGL is a horrible experience there are many small libraries that make it easier to use WebGL. A shader is a small program written in a language very similar to C which is compiled by the browser and run on the graphics card. This small C program takes input, and the output of the C program is what you see on the screen. Here is a very simple example of what it could look like:

const vertexShader = `
  precision highp float;

  // here is an attribute
  attribute vec3 position;

  varying vec2 uv;

  void main() {
    gl_Position = vec4(position, 1.0);
    uv = position.xy;
  }
`;

const fragmentShader = `
  precision highp float;

  // and here is a uniform
  uniform float time;

  varying vec2 uv;

  void main() {
    gl_FragColor = vec4(0.5*(uv+1.0), 0.5*(cos(time)+1.0), 1.0);
  }
`;

const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl");

const programInfo = twgl.createProgramInfo(gl, [vertexShader, fragmentShader]);

const bufferInfo = twgl.createBufferInfoFromArrays(gl, {
  // this is the attribute again
  position: [-1, 0, 0, 0, -1, 0, 1, 1, 0],
});

requestAnimationFrame(function render(time) {
  twgl.resizeCanvasToDisplaySize(canvas);
  gl.viewport(0, 0, canvas.width, canvas.height);

  gl.useProgram(programInfo.program);
  twgl.setBuffersAndAttributes(gl, programInfo, bufferInfo);
  twgl.setUniforms(programInfo, {
    // and this is the uniform again
    time: time / 1000,
  });
  twgl.drawBufferInfo(gl, bufferInfo);

  requestAnimationFrame(render);
});

This code results in this fantastic looking WebGL demo:

This is about as simple of a WebGL program you can have, but don't worry if it looks intimidating, we don't really need to understand how it works. The important bits are the ones I have commented. There is one attribute (position) and one uniform (time), and they show up twice, once in the shaders and once in the js code. For the WebGL code to work the attributes and uniforms used in the shaders have to be given a value in the js code, and they have to be given the correct type of value. If the value isn't set, the name is spelled wrong or the wrong js type is given, then the code will either not behave correctly or not work at all. So, it would be useful to have TypeScript check for us if we have set the right attributes and uniforms for us, by comparing the TypeScript code with the shader program. But that means we have to teach TypeScript to understand a C program string. So let's do that!

type Uniform = `uniform ${string} ${string}`;
// this is ok
const success: Uniform = "uniform vec2 something";
// this is not ok
const failure: Uniform = "not a uniform";
const failure: Uniform = "uniform unknown something";
const failure: Uniform<"float", "something"> = "uniform vec2 something";
const failure: Uniform = "uniform unknown something";
type GetUniform<T extends string> = T extends Uniform<infer Type, infer Name>
const failure: Uniform<"float", "something"> = "uniform vec2 something";
type GetUniform<T extends string> = T extends Uniform<infer Type, infer Name>
type GetUniform<T extends string> = T extends Uniform<infer Type, infer Name>
type GetUniform<T extends string> = T extends Uniform<infer Type, infer Name>
type GetUniform<T extends string> = T extends Uniform<infer Type, infer Name>
type GetUniform<T extends string> = T extends Uniform<infer Type, infer Name>
type GetUniform<T extends string> = T extends Uniform<infer Type, infer Name>
type GetUniform<T extends string> = T extends Uniform<infer Type, infer Name>
const programInfo = twgl.createProgramInfo(gl, [vertexShader, fragmentShader]);
const programInfo = twgl.createProgramInfo(gl, [vertexShader, fragmentShader]);

In this code we have a type called Uniform and it is defined using the template literal syntax. We can use this type on a variable, and that makes it only possible to assign values to that variable that matches the string pattern. That means variables of type Uniform have to start with the string uniform and then contain some more stuff (with at least one space between any string values). In the code example at the beginning of the article you can see that in the fragmentShader there is one line with uniform float time which will match this pattern.

You can find more information about template literal types in the TypeScript documentation.

Now we have limited the types our uniform can have. The last line now has the correct format, but it uses an unknown uniform type, one that isn't in the list of UniformType, so it's not allowed, and the TypeScript compiler will complain.

Here the Uniform is made generic, so we now have to specify the type and the name that it can have. This might not seem very useful, the example gets quite verbose, but it will be useful in the next step.

Ok, now we have introduced conditional types as well. Things are starting to get weird. The GetUniform type is a conditional type that checks if its generic parameter T matches the pattern of a Uniform. If it does, it is able to infer what the two missing generic parameters Type and Name are. The really weird thing here is that we say that if it matches the pattern, then it will be a tuple that contain the type and name, and if it doesn't match, then it will be unknown. You can read more about conditional types and inference in the TypeScript documentation.

I have left out the implementation of the function test here. It will use some deep webgl functionality to be able to return what we declare it will return. Sometimes when working with TypeScript you have to ignore what happens inside a function and just say what it will return, and this is one of those cases. This is when the conditional types are useful, as we can say what it will return depending on the parameters without having to say how it will work.

Getting a tuple isn't that useful, it's much more useful to get an object that we can use. The type Record is a built-in type in TypeScript, and it is the type for an object with keys and values. The way it is used here produces an object with a single key, which is the Name, and a value that is the string Type. Therefore result is now an object, which we can assign a value to! The value we can assign to it is currently limited to the string value vec2 though, since that is what we got from the line of C code. What we really want to assign here is either a number or an array of numbers, since that is what the shader program expects to get. So we need to somehow map from the types in UniformType to the correct TypeScript type.

One more conditional type. Here we check if the the Type is 'float', and if it is, the real type should be number, but if it is one of the other options (vec2, vec3, ..., mat4) then should be an array of numbers. The last line shows that we can now set an array of numbers to our uniform something, because it was of type vec2. We might have done something a bit more clever to limit the lengh of the arrays assignable, so that a vec2 becomes a [number, number], while a mat4 becomes an array with 16 numbers, but let's leave it like it is for now. The type of result is inferred from the return value of test(), so we don't need to specify it. TypeScript will know what the type is based purely on the string we passed to test() and it will warn us if we do something wrong, like in the last line, where we assign a number to something that should be an array of numbers.

The limitation we have had until now is that we only support a single line of code. That's not very useful, so let's have a look at working with a multi-statement codesnippet. Here there is a new conditional type called GetAllUniforms. It looks for semicolons in strings and infers what comes before and after the semicolon. It then takes the Statement and passes it to GetUniform, and then it passes the rest of the string to itself recursively. This way it is able to handle multiple statements and merge it all together as as single object using the & operator. There is a limitation here that our code has to be on one line, which we want to change.

I've introduced here the Trim type, which uses a combination of conditional types and template literal types to trim away newlines and spaces. This way we can format our two uniform statements on separate lines. Note also that we can have more than just those two statements, since the GetUniform type will turn strings that don't match what it expects into unknown, and this is ignored by the & operator. That is, {a: number} & {b: number} & unknown is the same as {a: number, b: number}.

This is the interface of Twgl that we have to implement. The first method, createProgramInfo, takes a tuple of shaders and returns something, we don't really know what it is, but it is ProgramInfo. The second method takes the programInfo together with a uniforms object. I've indicated here that it is an Record where the name is a string and the type is either a number or an ArrayLike<number>. Currently this isn't very useful, there is no way to ensure that the uniforms passed to setUniforms contains all the right keys with the correct types. So let's replace some of the types with the clever stuff we have already written.

The big change here is to make everything generic on the two shaders, the vertex shader VS and the fragment shader FS. This way the setUniforms method can declare that the second parameter, the uniforms, depends on those two shaders. Note that it uses GetAllUniforms<VS> & GetAllUniforms<FS> to extract all the uniforms from both the vertex shader and the fragment shader and combine them into a single object. TypeScript is able to infer all of these generic parameters, so we don't have to specify them in our code.

So now, given the vertexShader and the fragmentShader the twgl.setUniforms() method will complain if the time: 10 line is missing.

This is of course just a simple demonstration, and it does not handle all scenarios and it can be improved quite a bit. For example, since it splits on semicolon only, it's not able to handle comments. Comments start with // and end at a newline, but we don't split our code on newlines, only on semicolon, so the statement on the line after a comment is included with the comment. Note also that since we split on semicolon we don't actually parse the code very well, but that's not necessary since we are only interested in getting the uniforms, and they are all declared at the top level. Therefore we don't care that the parsing doesn't understand { and }.

The code currently only extracts the uniforms, it would be useful to extract the attributes as well. It can be done in almost exactly the same way, just producing a different shape of the object. And I have skipped the uniform types sampler2D and samplerCube, but it shouldn't be too difficult to implement them too.

A lot of the code above is based on work done by others, for example the ts-sql I mentioned earlier. There is a curated list of awesome template literal types and examples by very clever people. TypeScript has become very powerful as of version 4.1.

I think this could be a very useful way for TypeScript code to suddenly understand other languages, like CSS or SQL. I've created a feature suggestion for the TypeScript language to support importing other files as strings, and then using template literal types and conditional types be able to parse the string and produce useful types that can be used in projects.

Did you find a mistake or have a suggestion for an improvement? Let me know or fork it and send me a pull-request.