Design
Money is Decimal
1234.50 in source code is a Decimal, not a binary floating-point number. Write 1234.50f for a Float.
(0.1 + 0.2) printNl. "0.3"
(1.00 / 3) printNl. "error: not exact"
((1.00 / 3) roundedTo: 2) printNl. "0.33"
Division that is not exact is an error. Rounding takes a number of digits and a direction: roundedTo: truncatedTo: floorTo: ceilingTo: awayFromZeroTo:. dividedBy:roundedTo: divides and rounds in one step.
The default is Decimal because business systems cannot tolerate rounding errors in money.
Int is a BigInt. Digits are not lost above 253.
Dates are calendar dates
Date is a calendar date with no time and no time zone.
A JavaScript Date is a point in time, not a calendar date. If a user in Tokyo picks August 10, it becomes midnight on August 10 in Tokyo time. Serialized to JSON, it is converted to UTC as 2026-08-09T15:00:00.000Z. A server that reads the date part stores August 9. An LPScript Date has no time, so this cannot happen.
Date arithmetic follows the calendar. One month after January 31 is February 28 (29 in a leap year).
There are four date and time types.
| Type | Contents |
|---|---|
Date | A calendar date. No time, no time zone |
Time | A time of day. No date, no time zone. For cut-offs and business hours |
DateTime | A date and a time. No time zone. For values a user types and DATETIME columns |
Instant | An absolute point in time |
Time values cannot be added. Whether 30 minutes after 23:45 is the next day or an input error cannot be determined from a time of day alone. Convert to seconds, compute, and convert back with Time fromSeconds:, which raises on out-of-range values.
DateTime is not an absolute time. 2026-03-08 02:30 occurs twice in some time zones and never in others. Converting between DateTime and Instant takes a time zone offset in minutes. There is no time zone database, so zones cannot be specified by name.
JSON keeps its digits
JSON.parse turns 1234567890123456789.12 into 1234567890123456800. LPScript parses JSON itself and keeps numbers as the text they arrived as. Conversion to a type happens when the value is read.
(j at: 'total') asDecimal "Some(1234567890123456789.12)"
at: does not throw. Sent to a value that is not an object, it returns the null Json, so (j at: 'a') at: 'b' can be written without checking each step. Type conversion returns a Maybe, which is none if the value does not parse.
Machine-written and user-typed strings
asInteger, asDecimal, asDate, asTime, and asDateTime parse strings strictly. '1,000' is none. This catches malformed data from external systems.
Strings typed by a user are not in strict form. They contain full-width digits like 10, thousands separators, slash-separated dates, and single-digit hours like 9:05. For these there are asEnteredInteger, asEnteredDecimal, asEnteredDate, asEnteredTime, and asEnteredDateTime. They normalize the notation, then parse strictly. 12x is still none, and 2026/2/30 is still not a date.
There are two families because the two sources require opposite results for the same input. When only the strict parsers existed, the following code silently restored the old quantity for any user who typed 1,000:
draftQty asInteger orElse: [o qty]Type system
Type inference is Hindley-Milner. Type annotations are allowed but not required. There is no subtyping.
Message sends are type-checked in three stages. If the receiver's type is known, the selector is checked immediately. If not, the check is deferred until the type is determined elsewhere. If the type is never determined, the send is accepted.
The third stage is a deliberate gap. Only unreachable code passes through it: reaching such a send requires a value whose type was never determined, and constructing a value determines its type.
Classes are nominal. There is no inheritance. Shared behavior is expressed with protocols.
Protocols
There is no case. To express "one of these," define a class per case and declare the required methods in a protocol.
Protocol subclass: #Status requires: (#label #badge #next).
If a class declares the protocol and omits a method, it is a compile error.
Cancelled claims protocol Status but does not implement "colour"
In a language with sum types and case, adding a case produces compile errors for missing branches. In LPScript, adding a case produces compile errors for missing implementations.
Values that can cross threads
An ActorRef does not record which thread the actor runs on. The code is the same whether the actor is on the same thread or in a Web Worker. Therefore every value passed in a message must be copyable.
Blocks are not copyable, because they refer to variables in the scope where they were created. Placing a state that contains a block on a Worker is a compile error that names the offending field.
Passing a Decimal to a JavaScript function is also a compile error. JavaScript has no decimal type, so the caller must choose between keeping the digits (asString) and computing (asFloat).
Unit of type checking
Modules connected by imports: are parsed and inferred together. There is no separate compilation.
The reason is that three checks require the whole program. A protocol's method types are determined from every class that implements it. What a become: target must accept is determined from every place the reference was created. Whether a value can cross a thread is determined by walking the values a message carries.
Type-checking speed is not a problem. A 280-line order screen type-checks in 0.000 seconds (below the timer's resolution). An 81,000-line program takes 1 second, and the time is linear in program size.