Writing Declarations

The real power of the transpiler is unlocked when combining it with declarations for your target environment. Declarations tell TypeScript which Lua API is available in your target context.

If you need tips or help writing declarations, feel free to join our Discord.

About Declaration Files#

Declaration files end with the extension .d.ts. These contain pure ambient code.

For TypeScriptToLua, these files should contain information that describes the target Lua environment.

This means functions, modules, variables and other members of the target Lua environment are primarily described in these files.

They don't contain code that you would execute. Similar to how you'd write an interface in some other languages. TypeScriptToLua doesn't output any information from these files either.

note

You can write ambient declarations inside .ts files as well.

Declare Keyword#

The declare keyword is used to say that the following declaration defines something that exists within global scope. Like something within the _G table in Lua.

This is useful for defining Lua's environment.

_G.d.ts
Playground
// Uses some declarations from
// https://www.lua.org/manual/5.1/manual.html
/**
* A global variable (not a function) that holds a string containing the
* current interpreter version.
*/
declare const _VERSION: number;
/**
* Receives any number of arguments, and prints their values to stdout, using the
* tostring function to convert them to strings. print is not intended for
* formatted output, but only as a quick way to show a value, typically for
* debugging. For formatted output, use string.format.
* @param args Arguments to print
*/
declare function print(...args: any[]): void;
main.ts
Playground
print(_VERSION); // Editor and transpiler know what print and _VERSION are
note

You can use declare to write ambient declarations inside .ts files.

Export Keyword#

The export keyword indicates something is exported and can be used by external code.

This also includes ambient interfaces, types, modules and other items that don't result in any transpiled code.

If a file named lib.lua exists and returns a table with an x field, you can write lib.d.t.s as follows to tell TypeScript that lib exists and what it provides.

lib.d.ts
Playground
export let x: number;
main.ts
Playground
import { x } from "./lib";

If a namespace contains certain functions, export tells TypeScript that those functions can be accessed within the namespace.

table.d.ts
Playground
declare namespace table {
/**
* @noSelf
*/
export function insert(table: object, item: any): number;
}
main.ts
Playground
table.insert({}, 1);

If a globally available module exists within the Lua environment. You can define what the module provides.

utf8.d.ts
Playground
declare module "utf8" {
/**
* @noSelf
*/
export function codepoint(): void;
}
main.ts
Playground
import * as utf8 from "utf8"; // equiv to `local utf8 = require("utf8");
utf8.codepoint();

The export keyword can be used in a .ts or .d.ts file. It tells the transpiler and your editor (potentially) that something contains/provides something that you can either import (by using import in TS or require() in Lua) or access.

Self Parameter#

TypeScript has a hidden this parameter attached to every function.

This causes TypeScriptToLua to treat every function as if self exists as its first parameter.

Playground
declare function assert(value: any): void;
// TypeScript: assert(this: any, value: any): void;
// TypeScriptToLua: assert(self, value)
assert(true); // assert(_G, true)

This allows users to modify this inside a function and expect behaviour similar to what JavaScript does.

But obviously Lua does not have a self parameter for every function, so one of the three options must happen to tell TypeScriptToLua there is no "contextual parameter" (self):

  1. Use this: void as the first parameter of the function / method. This formally describes to TypeScript to not allow this to be modified inside this function. (you could also use the noImplicitThis option to disallow this to be modified if this is of an any type).
  2. Use @noSelf in the comments of the declaration's owner (the namespace, module, object, etc).
  3. Use @noSelfInFile at the beginning of the file in a comment to make sure every function defined in this file does not use a "contextual parameter".

Below is three ways to make table.remove not use a "contextual parameter".

Playground
declare namespace table {
export function remove(this: void, table: object, index: number): any;
}
Playground
/** @noSelf */
declare namespace table {
export function remove(table: object, index: number): any;
}
Playground
/** @noSelfInFile */
declare namespace table {
export function remove(table: object, index: number): any;
}

By doing this, the transpiler also figures out if it needs to use : or . when invoking a function / method.

Comments and Annotations#

If you're using an editor that seeks out information about functions, variables, etc. It will likely find the file where what it is analyzing is defined and check out the comment above it.

Playground
/**
* When hovering over print, this description will be shown
* @param args Stuff to print
*/
declare function print(...args: any[]);

Try out what this looks like in an editor

TypeScript uses TSDoc for its comments. TSDoc allows you to also use markdown in your comments! This means pictures, links, tables, code syntax highlighting and more markdown features are available. These may display differently depending on the editor in use.

Here are some commonly used TSDoc tags used in comments:

TagDescription
@param <name> <description>Defines a parameter. e.g. A parameter for a function
@return <description>Describes the return value of a function / method

TypeScriptToLua takes this further. Some "tags" change how the transpiler translates certain pieces of code. These are referred to as annotations.

As an example, @tupleReturn marks a function as something which returns multiple values instead of its array.

Playground
/**
* Returns multiple values
* @tupleReturn
*/
declare function tuple(): [number, number];
let [a, b] = tuple();
// local a, b = tuple()
Playground
/**
* Returns a table array containing two numbers
*/
declare function array(): [number, number];
let [c, d] = array();
// local c, d = unpack(array())

See Compiler Annotations page for more information.

Environmental Declarations#

By default, TypeScript includes global type declarations for both ECMAScript and web standards. TypeScriptToLua aims to support only standard ECMAScript feature set. To make TypeScript not suggest you to use unsupported browser builtins (including window, document, console, setTimeout) you can specify a lib option:

tsconfig.json
{
"compilerOptions": {
"lib": ["esnext"]
}
}

It is also possible to use noLib to remove every standard declaration (to use TypeScriptToLua only for syntactic features with Lua standard library) but TypeScript needs certain declarations to exist so they will have to be manually defined, so using noLib is not recommended.

Advanced Types#

We recommend reading about Mapped and Conditional types. These things can be used as effective tools to describe some dynamic things that you may have in Lua.

Declaration Merging#

https://www.typescriptlang.org/docs/handbook/declaration-merging.html

Some examples of declaration merging have been shown in the above examples.

Function + Table#

Some tables can use __call to make themselves callable. Busted (the Lua testing suite) does this to assert.

assert.d.ts
Playground
declare function assert(value: any, errorDescription?: string): void;
declare namespace assert {
export function isEqual(): void;
}
main.ts
Playground
assert.isEqual();
assert();

Declaration Examples#

Interfaces#

image.d.ts
Playground
interface Image {
/** @tupleReturn */
getDimensions(): [number, number];
}
// This interface merges with its previous declaration
/** @noSelf */
interface Image {
getFlags(): object;
}
main.ts
Playground
declare let image: Image;
let [w, h] = image.getDimensions(); // local w, h = image:getDimensions()
let o = image.getFlags();

Namespaces#

love.d.ts
Playground
declare namespace love {
export let update: (delta: number) => void;
/** @tupleReturn */
export function getVersion(delta: number): [number, number, number, string];
export namespace graphics {
function newImage(filename: string): Image;
}
}
// This namespace merges with its previous declaration
/** @noSelf */
declare namespace love {
export let update: (delta: number) => void;
}
/** @noSelf */
declare namespace string {
function byte(s: string, i?: number, j?: number): number;
}
main.ts
Playground
let [a, b, c, d] = love.getVersion();
let p = love.graphics.newImage("file.png");

Classes#

Because Lua doesn't have a strictly defined concept of a class, for TypeScriptToLua class declaration implies a very specific structure, built specifically for TypeScript compatibility. Because of that, usually you shouldn't use declare class for values coming from Lua.

Most of Lua patterns used to simulate classes can be declared using interfaces instead.

Example 1: a table with a static new method to construct new instances

Box = {}
Box.__index = Box
function Box.new(value)
local self = {}
setmetatable(self, Box)
self._value = value
return self
end
function Box:get()
return self._value
end
Playground
interface Box {
get(): string;
}
interface BoxConstructor {
new: (this: void, value: string) => Box;
}
declare var Box: BoxConstructor;
// Usage
const box = Box.new("foo");
box.get();

Example 2: a callable table with extra static methods

Box = {}
local instance
function Box:getInstance()
if instance then return instance end
instance = Box("instance")
return instance
end
setmetatable(Box, {
__call = function(_, value)
return { get = function() return value end }
end
})
Playground
interface Box {
get(): string;
}
interface BoxConstructor {
(this: void, value: string): Box;
getInstance(): Box;
}
declare var Box: BoxConstructor;
// Usage
const box = Box("foo");
box.get();
Box.getInstance().get();

Ambient Modules#

You may have to use the @noResolution annotation to tell TypeScriptToLua to not try any path resolution methods when the specified module is imported.

Module declarations need to be kept in .d.ts files.

types.d.ts
Playground
/** @noSelf */
declare module "image-size" {
export function getimagewidth(filename: string): number;
export function getimageheight(filename: string): number;
}
/**
* A module that only contains a number
* @noResolution
*/
declare module "number-of-the-day" {
let x: number;
export = x;
}
/**
* Not very useful for TypeScript. It has no idea what is in here.
* @noResolution
*/
declare module "custom-module";
main.ts
Playground
import { getimagewidth, getimageheight } from "image-size";
import * as x from "number-of-the-day";
import * as customModule from "custom-module";

Unions#

Unions can be used to tell TypeScript that a given type could be one of many other types. TypeScript can then pick up hints in the code to figure out what that type is at a given statement.

Playground
declare interface PingResponse {
type: "ping";
timeTaken: number;
}
declare interface MessageResponse {
type: "message";
text: string;
}
declare type Response = PingResponse | MessageResponse;
declare let response: Response;
response.timeTaken;
// Not allowed, if response is a MessageResponse, it won't have a timeTaken field
switch (response.type) {
case "ping":
// If the program arrives here, response: PingResponse
return response.timeTaken;
case "message":
// If the program arrives here, response: MessageResponse
return response.text;
case "disconnect":
// Impossible
default:
// Because of what Response is described as, TypeScript knows getting
// here is impossible.
}

keyof#

Playground
declare interface AvailableFiles {
"player.png": any;
"file.txt": any;
}
declare function getFile(filename: keyof AvailableFiles): string;
getFile("player.png"); // Valid
getFile("unknown.png"); // Invalid

Literal Types#

String and number values can be used as types too. In combination with union types it can be used to represent a known set of values.

Playground
declare function drawLine(type: "solid" | "dashed"): void;
drawLine("solid"); // Valid
drawLine("rounded"); // Invalid
Playground
declare function getSupportedColors(): 1 | 8 | 256 | 16777216;
getSupportedColors() === 8; // Valid
getSupportedColors() === 16; // Invalid

Keyword Workarounds#

Some functions in Lua can have names that are keywords in TypeScript (e.g., try, catch, new, etc).

The parent to these kinds of functions will need to be represented as a JSON object.

Playground
// โŒ
declare namespace table {
export function new: () => any;
}
// โœ”
declare let table: {
new: () => any;
};
Playground
// โŒ
declare module "creator" {
export function new: () => any;
}
// โœ”
declare module "creator" {
let exports: {
new: () => any;
};
export = exports;
}

Operator Overloads#

Lua supports overloading of mathematical operators such as +, - or *. This is performed using the metatable methods __add, __sub, __mul, __div, and __unm. Since TypeScript does not support operator overloading in its type system, this feature is hard to replicate. Unfortunately, this is not something that can be fixed properly right now without forking off our custom TypeScript version.

However, there are two possible workarounds. The first one is to declare a type as an intersection type with number. It will then inherit all mathematical operators. Keep in mind that this is only partially type safe and may require some additional casting.

Example:

Playground
declare type Vector = number & {
x: number;
y: number;
dot(v: Vector): number;
cross(v: Vector): Vector;
};
declare function Vector(x: number, y: number): Vector;
const v1 = Vector(3, 4);
const v2 = Vector(4, 5);
const v3 = (v1 * 4) as Vector;
const d = v3.dot(v2);

The second option was added in version 0.38.0. You can now use language extensions that allow declaring special functions which will transpile to operators. This will be completely type safe if the operators are declared correctly. See Operator Map Types for more information.

Import and export#

Using import can be important for making sure an index.d.ts file contains all the declarations needed.

index.d.ts
Playground
import "./lib";
// All global declarations in lib will be included with this file
export { Player } from "./Entities";
// The Player declaration is re-exported from this file

It is also possible to place import statements inside ambient modules and namespaces.

Playground
declare module "mymodule" {
import * as types from "types";
export function getType(): types.Type;
}

NPM Publishing#

It is possible to publish a list of declarations for other users to easily download via npm.

npm init
npm login # Need npm account
npm publish --dry-run # Show what files will be published
npm version 0.0.1 # Update the version in package.json when --dry-run seems good
npm publish # Publish to npm (only if you're 100% sure)

Then the user can install this package using:

npm install <declarations> --save-dev

And link it to a tsconfig.json file.

tsconfig.json
{
"compilerOptions": {
"types": ["declarations"]
}
}

Debugging Declarations#

If you have TypeScript installed, you can use the command below to list all files a tsconfig.json file targets.

tsc -p tsconfig.json --noEmit --listFiles

This only works with TypeScript (tsc). TypeScriptToLua (tstl) may have support for this in the future.

Every TypeScript project points to a list of declarations. TypeScript is very generous with what files that includes.

tsconfig.json
{
"compilerOptions": {
"rootDir": "src"
}
}
node_modules/
+ src/main.ts
+ src/actors/Player.ts
+ global.ts
tsconfig.json
tsconfig.json
{
"compilerOptions": {
"rootDir": "src",
"types": ["lua-types/jit"]
}
}
+ node_modules/lua-types/jit.d.ts
+ src/main.ts
+ src/actors/Player.ts
+ global.ts
tsconfig.json