Corecii / GreenTea

A runtime typechecker with Luau types, pretty errors, and inspection
MIT License
18 stars 1 forks source link

🍡 GreenTea

An experimental runtime typechecker for Roblox Luau, with matching 'tooling-time' Luau types.

Docs

Features

Experimental

While this package has had a lot of work put into it, it's largely an experiment. It's a first iteration, so the API can use some improvement β€” especially the structure of the generated Type objects, which could have a better shape.

Please leave feedback on the issues page. Thank you!

Install

with Wally (for Rojo)

  1. Install Wally
  2. Add GreenTea = "corecii/greentea@0.4.10" to your wally.toml

with pesde (for Rojo or Lune)

  1. Install pesde
  2. Run pesde add corecii/greentea\ or add GreenTea = { name = "corecii/greentea", version = "^0.4.10" } to your pesde.toml

Standalone (for non-Rojo)

How Does It Work?

GreenTea's runtime code looks like a typical runtime typechecker: you call functions to create typecheckers and compose them together.

It has two tricks:

  1. Its function type definitions lie and say that it's returning the type that you're checking for instead of a GreenTea.Type object. So while you're composing a runtime type, you're also composing a Luau type definition!
  2. The typechecker it produces is actually an object, which gives you a runtime-inspectable type definition you can do more advanced stuff with. This also means prettier errors that look a lot like Luau type errors.

Examples

Easy Mode

local gt = require(path.to.GreenTea)

local stringType = gt.build(gt.string())

-- equivalent to takesString(value: string)
local function takesString(value: typeof(stringType.type()))
    -- will error if value is not a string
    stringType:assert(value)

    -- ...
end

Using gt is recommended, because GreenTea is a lot to type and gt is not!

How it Works

local stringTypeRaw = gt.string()

-- stringTypeRaw is typed in Luau as `string`
-- but at runtime, it's really a GreenTea.Type object.
-- This makes it easy to split it up into a runtime
-- typechecker and a Luau type:

-- give a name to the type
type stringTypeLuau = typeof(stringTypeRaw.type())

-- cast the object to the runtime typechecker type it really is
local stringTypeChecker = gt.typecast(stringTypeRaw)

-- You can think of this like "tricking" Luau into
-- "giving" us a `string` type.
-- This is all `GreenTea.build` does -- it typecasts
-- the value into a GreenTea.Type object, but includes
-- the original type os you can use `typeof`.

-- But the magic is this also works for more complex types!

local catTypeRaw = gt.table({
    age = gt.number(),
    meowSound = gt.string(),
    breed = {
        [gt.string()] = gt.number(),
    },
})

type catTypeLuau = typeof(catTypeRaw)
-- which is the same as...
type catTypeSame = {
    age: number,
    meowSound: string,
    breed: { [string]: number },
}

-- and we can get a runtime typechecker...
local catTypeChecker = gt.typecast(catTypeRaw)

-- OR we can make it really easy on ourselves if we just use `build` from the beginning:

local catType = gt.build(gt.table{
    age = gt.number(),
    meowSound = gt.string(),
    breed = {
        [gt.indexer(gt.string())] = gt.number(),
    },
}))

-- catType == catTypeChecker
-- catTypeLuau == typeof(catTypeChecker.type())

Using build is recommended because it makes this mostly type-safe:

In this way, "built" types are for direct use, and "unbuilt" types are for composition with other GreenTea constructors.

Taking in GreenTea types

GreenTea is great for libraries that want to do typechecking, like RemoteEvent wrappers, because you can make the API very ergonomic:

-- RemoteWrapper.luau

function RemoteWrapper.new<T>(greenTeaType: T): RemoteWrapper<T>
    local self = {
        typechecker = GreenTea.build(greenTeaType),
        ...
    }

    return setmetatable(self, RemoteWrapper)
end

function RemoteWrapper.onServerEvent<T>(self: RemoteWrapper<T>, fn: (player: Player, params: T) -> ())
    self.event:OnServerEvent(function(player: Player, paramsMaybe: any?)
        local params = self.typechecker:assert(params)
        fn(player, params)
    end)
end
-- CatSpawner.luau

local SpawnCat = RemoteWrapper.new({
    cat = {
        age = gt.number(),
        meowSound = gt.string(),
        ...
    },
    location = gt.CFrame(),
    ...
})

SpawnVehicle:onServerEvent(function(player, params)
    -- params is properly typed as { cat: { age: number, ...}, location: CFrame, ... }
end)

Lune Support

GreenTea has not been extensively tested on Lune. Some Roblox-specific typecheckers might not work, and there may be other issues. Please report any issues here: https://github.com/corecii/greentea/issues

t Compatibility

GreenTea.t includes all of the methods from the t package. See the t package's readme for more details.

As a drop-in replacement

You can include the greentea-t-standalone package as an easy, drop-in replacement for a codebase which already uses t. Just add it to your wally.toml:

with Wally (for Rojo)

  1. Install Wally
  2. Add t = "corecii/greentea-t-standalone@0.4.10" to your wally.toml

with pesde (for Rojo)

  1. Install pesde
  2. Add t = { name = "corecii/greentea_t_standalone", version = "^0.4.10" } to your pesde.toml

This package just exports GreenTea.t to make drop-in replacement easy.

Inspiration

This library was largely inspired by t, but also my experience working with t types, working with Luau types, and working with systems that need inspectable type definitions. This is my first attempt at solving all of these problems. I'd really like to see the inversion of this library -- where inspectable runtime type definitions are built from Luau types. But that's more work, so I'll leave it as a TODO!

Future Plans