Skip to content

The jsii type system

Preamble

The base language for authoring jsii libraries for re-use from other languages is TypeScript, which compiles to JavaScript. Consequently, the base type system that jsii sources from is that of TypeScript.

When used from another language than TypeScript or JavaScript, jsii libraries are running the JavaScript code in a child node process, and data is exchanged using JSON-based protocol.

This document describes how TypeScript types map into the jsii type system.

The API represented by the jsii assembly only covers declarations that are exported from the main file in the TypeScript project (as specified in the package.json file by the types attribute). Restrictions described in this document only apply to such declarations, the rest of the module can leverage any TypeScript feature.

Basic Types

Introduction

In order to build useful programs, the simplest units of data need to be modeled: booleans, numbers, strings, etc... Those basic building blocks are the foundations on which APIs stand. jsii supports much of the same types that TypeScript and JavaScript support, although with notable differences.

Boolean

The jsii type system mirrors TypeScript's boolean, which is the simplest primitive data types, with only two supported values: true and false.

Number

The jsii type system mirrors TypeScript's number. All numbers are floating point values.

String

The jsii type system mirrors TypeScript's string. Strings are used to represent textual data.

List

TypeScript arrays (Array<T>, T[], ReadonlyArray<T> and readonly T[]) are represented as lists in the jsii type model. Lists are shared between the node process and the host process by-value, meaning a copy of the array is produced each time it is passed through the process boundary.

Info

Items in the list may be passed by-reference (according to their type's specification), in which case mutating operations performed on those may be visible across the process boundary.

Find out more here

Enum

As in many languages, enum can be used to represent a group of related constants. While TypeScript enum entries are associated with a value that is either a string or a number, the jsii type system does not allow for those to be down-casted to their value type (e.g: a string-valued enum entry cannot be directly passed into a string parameter).

Info

Unlike in certain languages such as Java, enum types cannot declare new properties or methods.

Find out more here

Any and Unknown

TypeScript defines two opaque types: any and unknown that can be used to represent a value of arbitrary type. The difference between them is that while any is assignable to any other type, unknown requires a type assertion or explicit cast to be performed before it can be assigned.

Both of these types map to an Any primitive type in the jsii type system, and the subtle distinction between any and unknown is lost in the process.

Info

It is important to note that, contrary to the other types in the TypeScript type system, any and unknown types are inherently null-able.

Void

As in most languages, the void type is used to denote a method does not return anything.

Find out more here

Null and Undefined

JavaScript differentiates undefined and null values. While undefined denotes that no value has been set, null denotes an intentional signal of there being no data. Most other programming languages (particularly statically typed languages) however lack this distinction, and the jsii type model consequently considers null and undefined are semantically equivalent.

Info

Unlike certain other programming languages, such as Java, TypeScript does not allow null (or undefined) values unless the type signature expressedly supports that (with the exception of any and unknown, which are implicitly null-able, as was discussed earlier).

Find out more here

Object

TypeScript's object type denotes anything that is not a primitive type, meaning anything other than a number, string, boolean, bigint, symbol, null or undefined.

In the jsii type model, object indicates a block of structured data that can be shared by-value across the process boundary. As a consequence, they may not include any method.

Unimplemented

This type is called Json in the current implementation.

Question

The by-value nature of object is problematic because TypeScript makes no guarantee with respects to the absence of methods on object, and properties may be dynamic.

Find out more here

Promises

jsii supports asynchronous methods, and the TypeScript Promise<T> type has to be used as the result of async methods. Promises can only be used as the result type of methods, not as the type of a property or parameter.

Unsupported TypeScript basic types

Due to how such types cannot be represented in many other programming languages, the jsii type model does not support the following TypeScript entities:

  • Tuples, a group of arbitrarily-typed values, often used as the result type for multi-valued functions.
  • The never type, which is used as the return type of functions that will not yield control back to their invoker (infinite loops, process.exit(), ...).
  • bigint and symbol don't have equivalents in many other programming languages and are generally of limited value in API design.

Complex Types

The goal of the jsii is to enable cross-language re-use of class libraries. TypeScript enables representing classic object-oriented concepts, such as classes and interfaces. The jsii type system supports some additional nuances on top of those, to better represent TypeScript and JavaScript idioms in a way that enables generating convenient APIs in other languages.

Classes

Exported TypeScript classes are represented in the jsii type system, with the following restrictions from plain TypeScript:

  • Methods overloads are not supported.
  • Overridden methods or properties must retain the exact same type signature as the one declared in a parent type. The jsii type system strictly enforces the Liskov substitution principle.

Interfaces & Structs

Exported TypeScript interfaces are interpreted as one of two entities in the jsii type system:

  • If the interface name is prefixed with an I (e.g: ISomething), it is interpreted as a behavioral interface.
  • Otherwise (e.g: Something), it is interpreted as a struct.

Behavioral Interfaces

Behavioral interfaces are the usual object-oriented interface: they can extend other behavioral interfaces, and can be extended by classes. They may however not extend structs.

Structs

Structs are used to model the JavaScript idiom of receiving options as an object literal passed as the last parameter of a function. They are a formal description of a bag of properties, and are not meant to be implemented by other types. Since those types are used as inputs, they can be handled as pure-data, immutable objects, and the following restrictions apply:

  • A struct cannot declare any method: they must be kept behavior-free.
  • All properties declared by a struct must be readonly. The values of the properties may however be mutable.

Structs may extend one or more other structs, but cannot extend or be extended by behavioral interfaces, and may not be implemented by classes.

Type Unions

In certain cases, several different kinds of values are acceptable for a given parameter or return type. TypeScript models those cases using type unions, which are represented as TypeA | TypeB. The jsii type model supports those, however most other statically typed languages do not have such a concept, making those parameters or return values difficult to use from those languages, as the value has to be declared using the most generic reference type available (for example, in Java, those are returned as java.lang.Object).

When used as inputs (parameters, or properties of a struct), it may be possible to generate method overloads that will allow for a convenient API in languages that support overloads.

In general however, type unions are discouraged and should only be used when there is no alternative way to model the API.

Find out more here

Serialization Behavior

When values are passed between the host process and the node process, they are serialized as JSON documents. They can be passed by value or by reference, depending on the type of the value as well as the declared type of the transfer point (method return type, property type, argument type, ...).

The table below describes the serialization behavior applied for each possible declared type (rows) for a value of a given dynamic type (columns). The ❌ sign expresses cases that are illegal and should cause immediate failure. The term primitive encompasses boolean, string, and number.

  undefined Date primitive Array instance object
void undefined undefined undefined undefined undefined undefined
Date undefined Date ❌ ❌ ❌ ❌
primitive undefined ❌ Identity ❌ ❌ ❌
enum undefined ❌ Enum ❌ ❌ ❌
List undefined ❌ ❌ Array ❌ ❌
Map undefined ❌ ❌ ❌ ❌ Mapping
interface undefined ❌ ❌ ❌ Reference Reference
struct undefined ❌ ❌ ❌ ❌ Value
class undefined ❌ ❌ ❌ Reference Reference
any undefined Date Identity Array Reference Value or Reference

In the case of object being passed though any, the value may be serialized by Value only if the value being passed does not have any method or dynamic accessor. Otherwise, it must be passed by Reference instead.

Danger

The serialization behavior around undefined values is affected by the optional attribute of the declared type. As discussed earlier, the any type is implicitly optional; but all other types' serialization process will only allow serialization of undefined if they were declared optional.

Array Serialization

Arrays are serialized into the standard JSON representation for them. Each value in the array is serialized according to the behavior dictated by the declared element type of the list, combined with the dynamic type of the value itself.

Date Serialization

JSON has no standard expression for Date. A special JSON object representation is used to allow unambiguously conveying a date. The wrapper has a single key ($jsii.date) with the ISO 8601-1 UTC representation of the Date value:

{ "$jsii.date": "2020-01-20T14:04:00.000Z" }

Enum Serialization

In JavaScript, enum entries are represented by their value equivalent. In order to support statically typed representations in other languages, these are serialized using a dedicated wrapper object, using a single key ($jsii.enum) with the fully qualified name of the enum entry (formatted as <enum type fqn>/<entry name>):

{ "$jsii.enum": "@scope/module.EnumType/ENTRY_NAME" }

Identity Serialization

The identity serialization is achieved by using the standard JSON representation of the primitive type. JSON strings are expressed using the UTF-8 character set.

Mapping Serialization

Key-value pairs are passed by-value between the processes and is wrapped using a single-key ($jsii.map) associated with the JSON representation of the encoded object; where values are serialized according to the behavior dictated by the element type of the mapping, combined with the dynamic type of the value itself:

{
  "$jsii.map": {
    "foo": {
      "date": { "$jsii.date": "2020-01-20T14:04:00.000Z" },
      "map": { "$jsii.map": {} }
    }
  }
}

Reference Serialization

Objects serialized by reference are passed using a special object that provides sufficient information to tie back to the instance within its owning process. It includes a $jsii.byref key associated with a string that uniquely identifies the instance, and an optional $jsii.interfaces key that provides a list of interfaces that the object implements.

{
  "$jsii.byref": "@scope/module.Foo@1337",
  "$jsii.interfaces": ["@scope/module.IBar", "@scope/module.IBaz"]
}

Value Serialization

Structs can be serialized by-value. In those cases, the value is wrapped using a special object that encapsulates the type information for the provided data as well as the struct's members.

The wrapper uses a single $jsii.struct key with a fqn key that indicates the fully qualified name of the struct type, and a data key that contains the members of the struct, serialized according to the behavior described in this document.

{
  "$jsii.struct": {
    "fqn": "@scope/module.StructType",
    "data": {
      "enumValue": { "$jsii.enum": "@scope/module.EnumType.ENTRY_NAME" },
      "stringProperty": "Hello, I'm a string!"
    }
  }
}

Submodules

Overview

Typescript allows grouping declarations together in namespaces, which are interpreted by jsii as submodules. Submodules names are the fully qualified name of the namespace from the package's root (if a package foo defines a namespace ns1, which itself contains ns2, the submodule for ns2 will be named foo.ns1.ns2).

Submodules are delcared in the jsii assembly under the submodules key. This is also where specific configuration is registered, if different from the parent submodule or package.

Submodules are hierarchical, and their fully qualified name is representative of the relationship. For example the assm.foo.bar submodule is considered to be nested under the assm.foo submodule.

Restrictions

Submodules cannot be involved in dependency cycles. While it is possible to build such cycles in JavaScript, that configuration cannot be reliably reprensented in certain other programming languages (e.g: Python).

Unimplemented

jsii does not currently check for circular submodule dependencies. Invalid dependency patterns may result in errors at code generation by jsii-pacmak, or at runtime.

Since this would result in ambiguity that cannot be consistently resolved, a given type can only be exported as part of one submodule.

Declaration

There are two supported ways to introduce submodules:

  • Using the namespaced export syntax:
    export * as ns from './module';
    
  • Using an explicit namespace declaration:
    export namespace ns {
      /* ... */
    }
    

Submodules declared using the export * as ns from './module'; syntax can be documented using a markdown document located at ./module/README.md.

Unimplemented

The ./module/README.md file support is not yet implemented.

Submodule Configuration

In languages where this is relevant (e.g: Python), submodules are rendered as native submodules. In languages where a namespace system exists (Java uses packages, C# uses namespaces, ...), submodules are rendered using that.

By default, submodule names are rendered appropriately in the target language (this typically involves adjusting the case of submodule name fragments to the idiomatic form in the language). In certain cases however, a developer can choose to use a different configuration by defining the submodule using the namespaced-export syntax (export * as namespace from './module-name';) by placing a .jsiirc.json file next to the entry point of the namespaced module. For example, if ./module-name's entry point is foo/bar/module-name/index.ts, the submodule configuration resides in foo/bar/module-name/.jsiirc.json.

Since submodules are hierarchical, the configuration of a given submodule defines the default configuration of submodules nested under it.

Code Generation

In order to generate code in various programming languages, jsii-pacmak needs configuration that provides naming directives (e.g: Java package names, C# namespaces, Python module names, ...). This configuration is language-specific and each language implementation specifies and documents its own configuration schema.

Configuration is sourced in the package.json file at the root of the npm package, under the special jsii key. The general schema is described in the configuration document.

Unimplemented

There is a proposition to allow this configuration to be placed in a .jsiirc.json file, which would take precedence over what is specified in package.json. Submodules introduced using the export * as ns from './module'; syntax would then be able to define submodule-local configuration using the ./module/.jsiirc.json file.

References

The TypeScript Handbook describes the language's type system and syntax elements that serve as the basis for the jsii type system. Additionally, the JavaScript type system is described in the JavaScript Fundamentals document.


Last update: 2021-09-24