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:
module main (shout)
import root-py
type LastName = Str
type Surname = LastName
source Py from "ops.py" ("to_upper" as shout)
shout :: Surname -> LastName
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.
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:
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:
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
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.
|
|
An unsupported form is dropped silently
That third line is a trap. The module declares Add the
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:
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
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:
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
-
An instance belongs to the root of an alias chain.
instance Foo MyAliaswheretype MyAlias = Baris rejected. Declare it onBar, or makeMyAliasanewtype. -
Every member of a
typechain shares the root’s instances. Withtype A = Strandtype B = Str, the singleinstance Eq Stris found at every site that mentionsA,B, orStr. -
A
typealias may not carry a per-language form.type Py ⇒ MyAlias = "…"is rejected. Usenewtype. -
A
newtypeis nominal. It owns its instances and its per-language forms. It needs aPackableinstance only when it declares a native form that the language binding cannot build from the wire form on its own. -
newtypewire-parent chains may not cycle.newtype A = Bwithnewtype B = AgivesMutual recursion between type definitions is not supported. Cycle: A, B.