Types
Primitive types, how each is stored, dates and times, edited fields, currency, and nullability.
Part of the BankTS language reference.
Primitive types
Supported primitive types:
bool;
decimal<precision, scale>;
string<length>;
national<length>;
date;
time;
timestamp;
Examples:
type AccountId = string<16>;
type CustomerId = string<20>;
type Amount = decimal<18, 2>;
3a. Dates and times
Banking is dates: a value date is not a posting date, an accrual runs between two of them, and a maturity is compared against today. They are separate types rather than aliases for a number, so a date cannot be compared with an amount, or with a plain integer that happens to have eight digits.
| Type | Storage | Holds |
|---|---|---|
date |
PIC 9(8) |
YYYYMMDD |
time |
PIC 9(6) |
HHMMSS |
timestamp |
PIC X(26) |
the Db2 host variable format |
PIC 9(8) as YYYYMMDD is the mainframe convention, and it is chosen for a
reason that matters: in that layout, ordinary numeric comparison is also
chronological comparison, and an ordinary sort is a chronological sort. A
timestamp is stored in Db2's own host variable format so it can be read from and
written to a TIMESTAMP column without conversion.
Dates order with <, <=, >, >=, ==, and !=, but only against the same
kind: comparing a date with a time, or with an amount, is BANK-TYPE-003.
Three builtins do the arithmetic:
let runDate: date = today();
let term: decimal<9, 0> = daysBetween(loan.openedOn, loan.maturesOn);
let grace: date = addDays(loan.maturesOn, 5);
These lower to the COBOL intrinsics that know the calendar — CURRENT-DATE,
INTEGER-OF-DATE, and DATE-OF-INTEGER — rather than to + on the stored
digits. That is the whole reason they exist: thirty days after the 31st of
January is the 2nd of March, which arithmetic on 20260131 would never produce.
A date is therefore not something you can add to directly, and a fraction of a
day is not a number of days (BANK-TYPE-003).
now() reads the clock and assembles a timestamp. CURRENT-DATE offers
hundredths of a second, so the last four digits of the microseconds are zeros
rather than invented.
3c. How a number is stored
decimal<p, s> is packed decimal, COMP-3, which is what a ledger amount is
held in. The others exist because a real estate's copybooks are full of them,
and a compiler that only knows COMP-3 cannot read those files at all:
| Declaration | Picture | Bytes | Used for |
|---|---|---|---|
decimal<p,s> |
PIC S9(p-s)V9(s) COMP-3 |
ceil((p+1)/2) |
money, and anything computed with it |
binary<n> |
PIC S9(n) COMP |
2, 4, or 8 | counters, sequence numbers, codes |
native<n> |
PIC S9(n) COMP-5 |
2, 4, or 8 | an interface to something outside COBOL |
zoned<p,s> |
PIC S9(p-s)V9(s) SIGN IS TRAILING SEPARATE |
p + 1 |
unpacked numbers a person reads |
unsigned<p,s> |
PIC 9(p-s)V9(s) |
p |
dates, counts and codes on an estate |
unsigned is the one that surprises people, and it is the most common numeric
picture in a copybook. PIC 9(8) carries no sign, so it is eight bytes rather
than nine and cannot hold a negative — assigning one stores its absolute value,
which is COBOL's rule and not something this compiler changes. It exists
because zoned is a byte wider, and importing a PIC 9(8) as a zoned<8,0>
would move every field after it.
A binary field is held in the halfword, fullword, or doubleword that fits its
declared digit count, which is how IBM Enterprise COBOL allocates COMP: 1–4
digits take two bytes, 5–9 take four, 10–18 take eight. More than eighteen is
BANK-TYPE-002 — a doubleword holds no more.
Zoned decimal is one byte per digit with the sign kept separate, so the field reads as plain text, which is what a file another system or a person reads needs.
native<n> is COMP-5, which holds the full range its storage can express
rather than truncating to the picture's decimal digits. That is what an
interface to something outside COBOL needs, and it is why the SQLCA uses it.
What a field starts as
record Counters {
processed: binary<9> = 0;
marker: string<1> = "N";
rate: decimal<5, 2> = 1.50;
state: Status = Status.OPEN;
}
A COBOL VALUE clause. Working storage starts as whatever the region left there
unless a field says otherwise, so a counter with no initial value starts at an
unpredictable number — and writing it in the record rather than in an opening
paragraph keeps the fact next to the field, where it cannot drift out of step
when the record gains one.
COBOL evaluates VALUE when it compiles, so the value has to be a written
number, string, boolean, or enum member of the field's own type, short enough to
fit (BANK-COPY-006). Anything that needs computing belongs in the program. A
redefines field cannot carry one at all: it has no storage of its own, only a
second reading of another field's bytes.
The clause is dropped when the same record is written into an FD, where COBOL
does not allow it — a file record describes a buffer the file fills, so there is
nothing there to initialise.
Alignment
counter: binary < 9 > sync;
sync aligns a binary field on a halfword or a fullword, and the compiler
inserts slack bytes before it to get there.
The boundary is not the field's own width. IBM's slack-byte algorithm
divides the bytes so far by 2 for a binary item of four digits or fewer and by 4
for one of five digits or more; there is no boundary of 8 for a binary item,
which belongs to COMPUTATIONAL-2. So a binary<18> occupies eight bytes and
still aligns on a fullword. Nothing else is aligned: a packed, zoned, or
character field needs no slack.
It is the one layout clause that moves every later field without appearing in any field's own length, so a copybook that uses it and a reader that ignores it disagree silently: every field after the first aligned one is read from the wrong place. The layout report accounts for the slack, and counts it in the record's length.
Usage is representation, not meaning. A count is a count whichever bytes hold it, so usage takes no part in type compatibility — only in the picture and the byte count. Currency stays nominally typed regardless: a BDT amount is still not an unqualified number that happens to have two decimals.
Naming a run of fields
record LegacyDate {
yearPart: zoned<4, 0>;
monthPart: zoned<2, 0>;
dayPart: zoned<2, 0>;
wholeDate renames yearPart through dayPart;
}
A legacy copybook splits a date into year, month, and day and then wants to move
all three at once. renames emits a level-66, which gives that run a second
name without a second copy of the storage — that is what distinguishes it from
redefines, which is a new reading of the same bytes.
It costs nothing and appears after the record's own fields, which is where COBOL
requires it. Both ends are qualified by the group in the generated code, because
the same record is emitted in working storage and again inside every FD that
holds it.
The name reads as the alphanumeric span it covers — string<11> above, since
zoned decimal is a byte per digit plus one for the separate sign — which is
exactly what a COBOL group move treats it as. Both ends have to be fields of the
record, the first has to come before the last, and the run cannot cross a table
whose length depends on a count, since a 66 has no length of its own
(BANK-COPY-004).
How a field is presented
reference: string < 12 > justified;
movement: (edited < GBP, "grouped" > blankWhenZero);
justified emits JUSTIFIED RIGHT. COBOL moves an alphanumeric value
left-aligned and pads on the right; this reverses it, which is how a code lands
in the right of a fixed column without the program counting spaces. Alphanumeric
only — a number's alignment comes from its picture (BANK-COPY-005).
blankWhenZero emits BLANK WHEN ZERO: a statement line with no movement
prints blank rather than 0.00, and says so in the record rather than in a
conditional. Numbers and edited fields only, since there has to be a zero to
blank.
sync, justified, and blankWhenZero may be written in any order.
National characters
given: national<20>;
national<n> emits PIC N(n) USAGE NATIONAL. The length counts characters;
Enterprise COBOL holds each in two bytes of UTF-16, so the field occupies
2n, and every field after it is placed accordingly. That arithmetic is the only
reason the type exists. A mainframe record with a national field does not line up
if the field is counted as n bytes, and a copybook that miscounts it puts
everything after it at the wrong offset.
It is a storage type, not a text type. A national may be assigned from, compared
with, and passed as another national of the same length; it can be read from and
written to files like any other field. What it cannot do is mix with
string<n> — in either direction, literals included:
name.given = "SMITH"; // BANK-TYPE-003
name.given = name.branch; // BANK-TYPE-003
The two hold different bytes for the same characters, and converting between them
needs NATIONAL-OF or DISPLAY-OF, which GnuCOBOL does not implement. Rather
than emit a move whose result differs between compilers, the compiler declines.
The caveat, stated plainly
A national field is the one thing this compiler emits that its own validation
does not cover, and every such field carries a warning (BANK-TYPE-024) saying
so.
GnuCOBOL 3.2.0 — the compiler everything else here is checked against — allocates four bytes per national character inside a group, not two. That is measured, not assumed:
01 H.
05 A2 PIC N(4) USAGE NATIONAL.
05 C2 PIC X(4).
C2 starts at byte 17 under GnuCOBOL and byte 9 under Enterprise COBOL.
GnuCOBOL also warns on every such line that its handling of USAGE NATIONAL is
unfinished, and allocates two bytes per character for the same picture at the 01
level, which makes it an inconsistency there rather than a rule.
This compiler emits the Enterprise COBOL width, because Enterprise COBOL is what
it targets. Verify the record on z/OS before relying on the offsets;
zos/README.md records the divergence as the first thing to
check.
3b. Edited fields
An amount held as COMP-3 cannot be printed. edited<T, "style"> declares the
rendering, and assignment into it is the formatting step — which is exactly what
a COBOL MOVE into a numeric-edited item does:
record StatementRow {
amount: MoneyBDT;
printedAmount: edited<MoneyBDT, "signed">;
}
row.printedAmount = row.amount; // MOVE AMOUNT OF ... TO PRINTED-AMOUNT OF ...
The picture is generated from the value's own precision and scale, so nobody
counts Zs:
| Style | Picture for decimal<18,2> |
Reads as |
|---|---|---|
"plain" |
ZZZZZZZZZZZZZZZ9.99 |
1234.50 |
"grouped" |
Z,ZZZ,ZZZ,ZZZ,ZZZ,ZZ9.99 |
1,234.50 |
"signed" |
Z,ZZZ,ZZZ,ZZZ,ZZZ,ZZ9.99- |
1,234.50- |
"credit" |
Z,ZZZ,ZZZ,ZZZ,ZZZ,ZZ9.99CR |
1,234.50CR |
"protected" |
*,***,***,***,***,**9.99 |
***1,234.50 |
"slashed" |
9999/99/99 (a date only) |
2026/08/05 |
Leading positions suppress and the last integer position stays 9, so a zero
amount prints as 0.00 rather than as nothing. Decimals never suppress: an
amount is read to the penny, and a blank penny column is a defect. Asterisk fill
is cheque protection — it leaves no room to write digits in. CR rather than a
minus is the accounting convention for a credit balance.
An edited field is a rendering, not a number. It may be assigned from a
value of its inner type and written to a file or a report line. It may not be
read back as a value, compared, or computed with — which is also what COBOL
allows, and which stops a report column becoming arithmetic input and losing
the digits the editing removed. A style the compiler does not know is
BANK-TYPE-023 rather than a picture passed through unchecked.
Currency types
Currency types are nominal and cannot be mixed implicitly.
type BDT = currency<"BDT", 18, 2>;
type USD = currency<"USD", 18, 2>;
Invalid:
let total: BDT = bdtAmount + usdAmount;
Valid:
let converted: BDT = fxConvert(usdAmount, rate, "HALF_EVEN");
Nullable values
Nullable values are explicit:
type OptionalBranch = nullable<string<8>>;
A nullable value must be checked before use.