5.4. Naming a type: type and newtype

Morloc gives you two keywords for putting a name on a type, and the choice between them decides whether the new name is the same type as the old one or a different one.

type X = Y is a transparent alias. X and Y are one type with two spellings, interchangeable everywhere.

newtype X = Y is a nominal type. X is a genuinely new type that happens to travel across language boundaries in the same format as Y. It owns its own typeclass instances and its own per-language representations, and a value cannot flow between X and Y without an explicit conversion.

A third form, a declaration with no right-hand side at all, declares an opaque primitive. That is covered at the end.

5.4.1. type: transparent aliases

An alias is fully substitutable with its right-hand side anywhere a type can appear — in signatures, annotations, container parameters, Packable instances, everywhere.

type Filename = Str
type UserID   = Int

A Filename goes wherever a Str is expected and a Str goes wherever a Filename is expected. Two aliases on the same chain are interchangeable with each other too: with type A = Str and type B = Str, an A flows into a B slot without conversion.

Aliases are useful for three things: naming (a signature reads better when a Filename is called a Filename), shortening long type expressions (type Coord = (Real, Real)), and attaching per-argument CLI documentation, which is described below.

5.4.2. Alias chains resolve on their own

Native type mappings showed how a general type is mapped to each language:

type Py => Str = "str"

You do not repeat that mapping for every alias. The compiler follows the chain until it finds a language-specific form, however many hops it takes:

alias.loc
module main (shout)

import root-py

type LastName = Str
type Surname  = LastName

source Py from "ops.py" ("to_upper" as shout)
shout :: Surname -> LastName
ops.py
def to_upper(s):
    return s.upper()
$ morloc make -o prog alias.loc
$ ./prog shout 'smith'
"SMITH"

Surname resolves to LastName, which resolves to Str, which resolves to "str" in Python. Writing type Py ⇒ Surname = "str" would be redundant — and, as the next section shows, is rejected.

5.4.3. Docstring inheritance

An alias inherits docstring directives from its parent and may override individual fields. This is what makes per-argument CLI documentation work: both aliases below are Str for typechecking and codegen, but each carries its own description.

crypt.loc
module main (encrypt)

import root-py

--' A secret key
--' metavar: KEY
type Key = Str

--' The message to encrypt
type PlainText = Str

--' An encrypted message
type CipherText = Str

--' Encrypt a message with a key
encrypt :: Key -> PlainText -> CipherText
encrypt k m = m <> k
$ morloc make -o crypt crypt.loc
$ ./crypt encrypt --help
Encrypt a message with a key

Usage: ./crypt <nexus_options> @ <command_options>

General Options:
  -h, --help
          Print help (see a summary with '-h')

Positional arguments:
  1:  A secret key
      type: Str    (literal string)
  2:  The message to encrypt
      type: Str    (literal string)

Return: CipherText
  An encrypted message
...

The metavar: KEY directive is recorded and reaches --json-help and --mcp-tools, but the positional-argument block of --help does not print metavars today. See the Building CLIs chapter for the full set of docstring directives.

newtype does not inherit docstrings. A newtype is its own identity and its own documentation.

5.4.4. What an alias cannot do

An alias has no identity of its own, so it cannot own anything.

It cannot have its own typeclass instances. The instance belongs to the root of the chain, and every alias on the chain shares it:

$ morloc typecheck aliasinst.loc
aliasinst.loc:4:1: error:
Cannot declare instance on transparent alias 'Filename'.
All members of an alias tree share a single instance. Either declare the instance for the root type, or change the declaration of 'Filename' from 'type' to 'newtype' so it becomes a nominally distinct type that owns its own instances.
  |
4 | instance Eq Filename where
  | ^

It cannot have its own per-language form. The chain has to resolve to one native type per language:

$ morloc typecheck aliaslang.loc
aliaslang.loc:4:1: error:
'Filename' is declared as a 'type' alias but has a per-language form for py.
Change 'type' to 'newtype' so 'Filename' becomes a nominally distinct type that owns its native language forms.
  |
4 | type Py => Filename = "pathlib.Path"
  | ^

Both errors tell you the fix: use newtype.

5.4.5. newtype: nominal types

A newtype is a new type that shares a wire format with the type on its right-hand side. Its instances, its native forms, and its identity are its own.

newtype Path = Str
type Py => Path = "pathlib.Path"
type Cpp => Path = "std::filesystem::path"

Path and Str are now different types, and mixing them is an error:

nomix.loc
module main (bad)

import root-py

newtype Path = Str
type Py => Path = "pathlib.Path"

f :: Path -> Path
bad :: Str -> Path
bad s = f s
$ morloc typecheck nomix.loc
nomix.loc:10:11: error:
Type mismatch:
  expected: Path
  inferred: Str
Cannot compare types Str and Path
   |
10 | bad s = f s
   |           ^

The wire format is still Str — a Path crosses a language boundary as a string — but inside each pool the value is a real pathlib.Path or std::filesystem::path.

5.4.6. When a newtype needs a Packable instance

A newtype crosses a language boundary as its wire parent. Whether anything has to convert that wire value into the newtype’s native form — and so whether you need a Packable instance — depends on one question: is the native form something the pool already has?

Declare no per-language form and the answer is yes. The newtype inherits its parent’s native form, so the value that arrives already is the right thing. No instance is needed, whatever the parent’s shape — a primitive, a list, a tuple, or another newtype. This is how the standard library’s Vector works in C++: vector-cpp declares no Packable instance for it at all, because newtype Vector (n :: Nat) a = List a and a List is already a std::vector.

Declare a form and it travels with the value as a schema hint. If the language binding knows how to build that form, you still need no instance. Python’s binding recognises bytes, bytearray, list, and numpy.ndarray (data/lang/py/pymorloc.c); numpy.ndarray is what puts tensor data on the zero-copy path.

Anything else needs a Packable. The instance is the general answer: it says how to build the native form from the wire form and back.

Three newtypes over Str, one of each kind:

forms.loc
module main (nameKind, blobKind, pathKind)

import root-py

newtype Name = Str

newtype Blob = Str
type Py => Blob = "bytes"

newtype Path = Str
type Py => Path = "pathlib.Path"

source Py from "native.py"
  ("kind" as nameKind, "kind" as blobKind, "kind" as pathKind)

nameKind :: Name -> Str
blobKind :: Blob -> Str
pathKind :: Path -> Str
native.py
import pathlib

def kind(x):
    return type(x).__name__

def str_to_path(s):
    return pathlib.Path(s)

def path_to_str(p):
    return str(p)

kind reports what the pool actually received:

$ morloc make -o forms forms.loc
$ ./forms nameKind notes/report.txt
"str"
$ ./forms blobKind notes/report.txt
"bytes"
$ ./forms pathKind notes/report.txt
"str"

Name inherits Str’s form and gets a `str, as declared. Blob asked for bytes and got one, with no instance, because the Python binding builds that hint. Path asked for pathlib.Path and got a str — the binding does not know that hint, and nothing said so.

Warning
An unsupported form is dropped silently

That third line is a trap. The module declares pathlib.Path, the pool receives a str, and there is no error and nothing on stderr. Your foreign function fails later, against a contract the compiler accepted.

Add the Packable instance and it is right:

instance Packable Str Path where
  source Py from "native.py" ("str_to_path" as pack, "path_to_str" as unpack)
$ ./forms2 pathKind notes/report.txt
"PosixPath"

Until this is caught at compile time, write the instance whenever you declare a per-language form outside the four the binding recognises.

A worked example with the instance in place, and a typeclass scoped to the new type:

path.loc
module main (ext, joined, absolute)

import root-py

newtype Path = Str
type Py => Path = "pathlib.Path"

instance Packable Str Path where
  source Py from "pathlib_ops.py" ("str_to_path" as pack,
                                   "path_to_str" as unpack)

class Filelike a where
  extension  :: a -> Str
  joinPath   :: a -> a -> a
  isAbsolute :: a -> Bool

instance Filelike Path where
  source Py from "pathlib_ops.py"
    ( "path_extension"   as extension
    , "path_join"        as joinPath
    , "path_is_absolute" as isAbsolute
    )

ext :: Path -> Str
ext = extension

joined :: Path -> Path -> Path
joined = joinPath

absolute :: Path -> Bool
absolute = isAbsolute
pathlib_ops.py
import pathlib

def str_to_path(s):
    return pathlib.Path(s)

def path_to_str(p):
    return str(p)

def path_extension(p):
    return p.suffix

def path_join(a, b):
    return a / b

def path_is_absolute(p):
    return p.is_absolute()
$ morloc make -o prog path.loc
$ ./prog ext 'notes/report.txt'
".txt"
$ ./prog joined '/home/z' 'notes.txt'
"\/home\/z\/notes.txt"
$ ./prog absolute 'notes.txt'
false

Filelike methods are available on Path and not on bare Str, which is exactly the constraint that makes the newtype worth declaring: a function over filesystem paths cannot be handed an arbitrary string.

5.4.7. Sharing a wire format across newtypes

newtype is how a family of related types share one serialized representation while keeping distinct behaviour. root declares Deque this way:

newtype Deque a = List a
instance Packable (List a) (Deque a)

Deque is a separate type from List — it has its own Stack and Queue instances, tuned to a deque’s performance profile — but it travels as a flat list, so on the command line it looks like one:

deque.loc
module main (pushFront, asList)

import root-py

pushFront :: Int -> Deque Int -> Deque Int
pushFront = cons

asList :: Deque Int -> [Int]
asList = unpack
$ morloc make -o prog deque.loc
$ ./prog pushFront 0 '[1,2,3]'
[0,1,2,3]
$ ./prog asList '[1,2,3]'
[1,2,3]

unpack is the Packable method that converts the native form back to the wire form; it is the explicit conversion the nominal distinction demands.

5.4.8. Declarations with no body

A declaration with no right-hand side introduces a primitive: nominal, opaque, owning its own per-language forms and instances, with no underlying Morloc representation.

newtype Int
newtype Str
newtype List a

type and newtype mean the same thing in this position — there is no alias to be transparent about — and the compiler treats them identically. Prefer newtype, which is what these declarations behave like. The standard library uses this form for every built-in type; internal/main.loc is a long list of them.

This is also how you declare a type that exists only in the foreign languages:

newtype Map key val
type Py  => Map key val = "dict" key val
type Cpp => Map key val = "std::map<$1,$2>" key val

Such a type needs a Packable instance to say what it looks like on the wire. That is the next section.

5.4.9. The rules

  1. An instance belongs to the root of an alias chain. instance Foo MyAlias where type MyAlias = Bar is rejected. Declare it on Bar, or make MyAlias a newtype.

  2. Every member of a type chain shares the root’s instances. With type A = Str and type B = Str, the single instance Eq Str is found at every site that mentions A, B, or Str.

  3. A type alias may not carry a per-language form. type Py ⇒ MyAlias = "…​" is rejected. Use newtype.

  4. A newtype is nominal. It owns its instances and its per-language forms. It needs a Packable instance only when it declares a native form that the language binding cannot build from the wire form on its own.

  5. newtype wire-parent chains may not cycle. newtype A = B with newtype B = A gives Mutual recursion between type definitions is not supported. Cycle: A, B.