Writing Declarations
The best way to use TypeScript is to provide it with information about the format/types of the external functions and variables that you will be using (specific to your environment). This allows the compiler to check your code for mistakes when compiling, instead of having to run the code to find issues. To give TypeScript this information, you will need to provide it with type declarations. You can write these declarations yourself or, if available, install an existing type declarations package for your environment from npm.
For more information about installing existing type definition packages, see the getting started page.
This page has more information about how to write your own type declarations. This can be tricky, so if you need help, feel free to join our Discord server.
About Declaration Files
Declaration files end with the extension .d.ts
(which stands for "declaration TypeScript file"). Declaration files are different from normal .ts
files in that they must only contain ambient code. In the context of TypeScript, ambient refers to code that only exists at compile-time and is not emitted into the program output.
In other words, anything you put into a .d.ts
file will inform the TypeScript compiler about what the format of something is. And it will never appear in the generated .lua
file(s).
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.
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.
// 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;
print(_VERSION); // Editor and transpiler know what print and _VERSION are
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.
export let x: number;
import { x } from "./lib";
If a namespace contains certain functions, export
tells TypeScript that those functions can be accessed within the namespace.
declare namespace table {
/**
* @noSelf
*/
export function insert(table: object, item: any): number;
}
table.insert({}, 1);
If a globally available module exists within the Lua environment. You can define what the module provides.
declare module "utf8" {
/**
* @noSelf
*/
export function codepoint(): void;
}
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.
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
):
- Use
this: void
as the first parameter of the function / method. This formally describes to TypeScript to not allowthis
to be modified inside this function. (you could also use the noImplicitThis option to disallowthis
to be modified ifthis
is of anany
type). - Use
@noSelf
in the comments of the declaration's owner (the namespace, module, object, etc). - 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".
declare namespace table {
export function remove(this: void, table: object, index: number): any;
}
/** @noSelf */
declare namespace table {
export function remove(table: object, index: number): any;
}
/** @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.
/**
* 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:
Tag | Description |
---|---|
@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.
/**
* Returns multiple values
* @tupleReturn
*/
declare function tuple(): [number, number];
let [a, b] = tuple();
// local a, b = tuple()
/**
* 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:
{
"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
Declaration merging is a feature of TypeScript that allows you to combine new declarations with ones that already exist. For more information, see the TypeScript documentation.
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
.
declare function assert(value: any, errorDescription?: string): void;
declare namespace assert {
export function isEqual(): void;
}
assert.isEqual();
assert();
Declaration Examples
Interfaces
interface Image {
/** @tupleReturn */
getDimensions(): [number, number];
}
// This interface merges with its previous declaration
/** @noSelf */
interface Image {
getFlags(): object;
}
declare let image: Image;
let [w, h] = image.getDimensions(); // local w, h = image:getDimensions()
let o = image.getFlags();
Namespaces
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;
}
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
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
})
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.
/** @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";
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.
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
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.
declare function drawLine(type: "solid" | "dashed"): void;
drawLine("solid"); // Valid
drawLine("rounded"); // Invalid
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.
// ❌
declare namespace table {
export function new: () => any;
}
// ✔
declare let table: {
new: () => any;
};
// ❌
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:
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.
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.
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.
{
"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.
{
"compilerOptions": {
"rootDir": "src"
}
}
node_modules/
+ src/main.ts
+ src/actors/Player.ts
+ global.ts
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