published: August 20, 2020 - last updated: March 4, 2022
Anyone who's worked with Rust (opens new window) knows that a huge chunk of why it's so excellent is the macro system. When using a statically typed language, there are simply so many programs that will have mountains of boilerplate if they can't automatically generate code.
Why is this the case? Well, when you've committed to using a typesafe language (which you should), there are many small tasks (such as mapping one type to another, or validating a type, or constructing a type with defaults) where the code simply mirrors the structure of the types, and can easily be generated by a rote algorithm. With this capability, the potential tedium of writing typesafe code can be largely removed, and the language is a joy. But without it, the language can become a chore. Typescript is one such language, and it's insanely incomplete without real macros.
Since WebAssembly can't directly interact with the dom (yet) (opens new window), it's an unfortunate reality for some of us that we can't use Rust in all our projects. So when we need to write browser code that performs well, typescript is the only available option that's acceptably typesafe (I'm a bit of a typesafety fundamentalist 😅). But typescript doesn't have macros (opens new window), and it doesn't seem the core team is terribly interested in the idea. So I decided to do it myself.
You may be saying: but typescript does have macros! (opens new window) However, it's very important to notice that the transformation process allowed by the typescript compiler doesn't typecheck the transformed output. This makes it unsafe and not very useful.
Mapped (opens new window) and Conditional (opens new window) types can do some of the work that traditional macros would usually do, but they aren't nearly complete. They can only transform the program on a type level, which basically just guards the dynamic nature of javascript with (leaky) type safety. True meta-programming, in contrast, can produce actual code.
macro-ts
macro-ts
is my attempt to solve the above problems, at least in the short term.
Here's the hypothetical example given in the project README (opens new window), which demonstrates all four types of macros: import, block, decorator, and function.
You could write code like this:
// an import macro
import ProductDetails from sql!!('./productDetails.sql')
// a block macro
db!!;{
port = 5432; host = 'db'
user = DB_ENV_USER
password = DB_ENV_PASS
}
// a decorator macro
@get!!('/product/{id}')
async function productDetails(id: number) {
const queryResult = await db.query(ProductDetails.compile(id))
// a function macro
const products = required!!(queryResult)
return { products }
}
and have it transformed into something like the following, and then fully typechecked:
// import macros can inspect the contents of real files
// and produce "virtual" typescript files
import ProductDetails from './productDetails.sql.ts'
// block macros can inspect a list of statements
// and expand to any other list of statements
import driver from 'some-database-driver-library'
const dbUser = process.env.DB_ENV_USER
if (!dbUser) throw new Error(`DB_ENV_USER isn't set`)
const dbPassword = process.env.DB_ENV_PASS
if (!dbPassword) throw new Error(`DB_ENV_PASS isn't set`)
const db = new driver.Pool({
port: 5432, host: 'db',
user: dbUser, password: dbPassword,
})
// a decorator macro can inspect the statement it's attached to
// choose to replace that statement
// and provide additional statements to place around it
import * as v from 'some-validation-library'
const productDetailsParamsValidator = v.object({ id: v.number })
async function productDetails(params: { [key: string]: unknown }) {
const paramsResult = productDetailsParamsValidator.validate(params)
if (paramsResult === null)
throw new v.ValidationError(params)
const { id } = paramsResult
const queryResult = await db.query(ProductDetails.compile(id))
// function macros can inspect the args it's passed
// return any expression to replace the function call
// and provide additional statements to place around it
if (queryResult === null)
throw new Error()
const products = queryResult.value
return { products }
}
app.get('/product/{id}', productDetails)
As you can see, a lot of tedium can be removed from your code, while still retaining all the delights of typesafety.
Some reasons I'm excited about this project:
macro-ts
could allow things like arbitrary domain specific languages or file preprocessing, all in a typesafe way. The implications of this flexibility are vast.macro-ts
enables nice clean reusable solutions for these situations.tsc
has always been missing both some kind of check
command that typechecks the code without actually compiling it, and a run
command. ts-node
did a great job filling that latter gap (and the run
command in macro-ts
uses basically the same internal mechanisms as ts-node
😇), but it's nice to have one tool that unifies all that functionality. Also, I really don't like json config files. json is such a cluttered and inflexible language to use for human consumption, so the config for macro-ts
is in toml
.ident!!
? It's so ugly!The syntax of the macros has to fit a few critera, which is made more difficult by the fact that macro-ts
is an unsanctioned hack on top of typescript. The syntax must be:
Typescript 2.0
added the non-null assertion operator (opens new window), which one can argue shouldn't be used since it cancels the benefits of the strict null-checking mode (opens new window) and can introduce unsafety. However there are times when it's reasonable, so we shouldn't just entirely hijack the operator.
However using the operator twice never really makes sense, and it's still technically valid according to typescript. So of all the options I was considering, !!
seemed the most reasonable.
It also doesn't hurt that !!
mimics the Rust macro syntax (opens new window).
Yeah I think so! Import macros certainly offer that possibility, and as I was designing how they work I kept potential applications like web app bundling in mind.
Right now I don't think it would be terribly easy to do, since I was just focused on getting this typescript-first application out the door. But in the near future I plan to generalize some of the compilation functions so macro-ts
could be the foundation of arbitrary specialized compilers, like a typesafe web application bundler.
This project is extremely rough around the edges. I wanted to get this project in front of people for feedback sooner rather than later, so I haven't really put in the elbow grease yet.
dev
mode concept I've implemented so far is too strict and inflexible, and probably not really useful.Feedback and pull requests are welcome! This project exists mainly to make my life easier, so whether a pull request gets merged will depend more on if I like it than if it's a reasonable idea. I'm sure we can still get along 🤗
I look forward to hearing from you!
Once again here's the repo: macro-ts
(opens new window).