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.
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!
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.