Interfaces and unions
This page covers the Fickl.Core attributes for binding JavaScript shapes that an ordinary F# interface or union cannot describe: callable objects, augmented global interfaces, fluent this-returning methods, tagged unions over interfaces, erased cases told apart by a predicate, labelled tuples and TypeScript type guards. Use them when you write bindings, or when TypeScript code consumes your F# types.
Callable objects: [<CallableInterface>]
Many JavaScript values are functions with properties: debug with debug.enabled, a React component with a displayName, Express middleware. Mark an interface [<CallableInterface>] and give it one Invoke method. An object expression implementing it compiles to a plain function, and Invoke calls compile to direct calls.
open Fickl.Core
[<CallableInterface>]
type Middleware =
abstract Invoke: request: string -> string
abstract path: string
[<CallableInterface>]
type Identity =
abstract Invoke<'T>: value: 'T -> 'T
let shout =
{ new Middleware with
member _.Invoke request = request + "!"
member _.path = "/" }
let identity = { new Identity with member _.Invoke value = value }
let handled = shout.Invoke "hello"
export const shout = Object.assign(function _(request) {
return request + "!";
}, {
path: "/",
});
export function identity(value) {
return value;
}
export const handled = shout("hello");
export interface Middleware {
(request: string): string,
get path(): string
}
export interface Identity {
<T>(value: T): T
}
export const shout: Middleware = Object.assign(function _(request: string): string {
return request + "!";
}, {
path: "/",
});
export function identity<T>(value: T): T {
return value;
}
export const handled: string = shout("hello");
The other properties and methods of the interface are put on the function with Object.assign, and shout.Invoke "hello" is shout("hello"). A generic Invoke stays generic: TypeScript declares Identity as <T>(value: T): T.
The interface declares one Invoke method with one argument group, or inherits callable interfaces without declaring Invoke. When it inherits several, they must share the same instantiated interface that declares Invoke.
Augmenting interfaces: [<AugmentGlobal>], [<AugmentModule>]
[<AugmentGlobal("Name")>] adds the members of an F# interface to a global TypeScript interface, declare global { interface Name { ... } }. [<AugmentModule("path", "Name")>] adds them to an interface a module exports, declare module "path" { ... }. TypeScript code that imports your file then sees what your F# library contributes, for example document.createElement("my-counter") returning your custom element.
open Fable.Core
open Fickl.Core
[<Global>]
type HTMLElement() =
class
end
type Counter() =
inherit HTMLElement()
member val count = 0 with get, set
[<AugmentGlobal("HTMLElementTagNameMap")>]
type TagNames =
[<EmitProperty("my-counter")>]
abstract counter: Counter
[<AugmentModule("vue", "ComponentCustomProperties")>]
type Translations =
abstract translate: key: string -> string
let translateAll (plugin: Translations) (keys: string list) =
keys |> List.map plugin.translate
import type { int32 } from "@fable-org/fable-library-ts/Int32.ts";
import { class_type, type TypeInfo } from "@fable-org/fable-library-ts/Reflection.ts";
import { type FSharpList, map } from "@fable-org/fable-library-ts/List.ts";
import type { ComponentCustomProperties } from "vue";
export class Counter extends HTMLElement {
"count@": int32;
constructor() {
super();
this["count@"] = 0;
}
get count(): int32 {
const __: Counter = this;
return __["count@"] | 0;
}
set count(v: int32) {
const __: Counter = this;
__["count@"] = (v | 0);
}
}
export function Counter_$reflection(): TypeInfo {
return class_type("Example2.Counter", undefined, Counter, class_type("Example2.HTMLElement", undefined, HTMLElement));
}
export function Counter_$ctor(): Counter {
return new Counter();
}
declare global {
interface HTMLElementTagNameMap {
get "my-counter"(): Counter
}
}
declare module "vue" {
interface ComponentCustomProperties {
translate(key: string): string
}
}
export function translateAll(plugin: ComponentCustomProperties, keys: FSharpList<string>): FSharpList<string> {
return map<string, string>((key: string): string => plugin.translate(key), keys);
}
The interface has no declaration of its own, and annotations of it are the augmented interface: translateAll takes a ComponentCustomProperties imported from "vue". A generic interface must have the same type parameters as the interface it augments. A relative module path is relative to the declaring F# file. Only the TypeScript output and declaration files change; an interface has no JavaScript.
Fluent methods: [<ReturnsThis>] and Fluent.chain
A query builder's where returns the builder, and in TypeScript a subinterface keeps its own members through the chain because where returns this. Mark such an abstract method [<ReturnsThis>] and TypeScript declares it returning this, where(arg0: string): this. The method must return its declaring interface or abstract class, or one of its bases.
F# still types select.where "x" as the declaring interface, IQuery. Fluent.chain makes the same call and types the result as the receiver, so the subinterface's members stay in reach.
open Fickl.Core
type IQuery =
[<ReturnsThis>]
abstract where: string -> IQuery
type ISelect =
inherit IQuery
abstract columns: string -> ISelect
let build (select: ISelect) =
select
|> Fluent.chain (fun s -> s.where "active")
|> fun s -> s.columns "name"
export function build(select) {
const s_1 = select.where("active");
return s_1.columns("name");
}
export interface IQuery {
where(arg0: string): this
}
export interface ISelect extends IQuery {
columns(arg0: string): ISelect
}
export function build(select: ISelect): ISelect {
const s_1: ISelect = select.where("active");
return s_1.columns("name");
}
The JavaScript is unchanged. F# classes and object expressions implementing the method return this too, with an as this cast in TypeScript: the attribute promises that the method returns its receiver, or an object of the receiver's type. Fluent.chain casts its result, which fails on .NET if the member returned another type.
Tagged unions over interfaces: [<InlineInterface>]
A [<TypeScriptTaggedUnion>] case normally holds its fields in an object next to the tag. [<InlineInterface>] on a case with exactly one field, whose type is an interface, compiles the case as that interface with the tag added: TypeScript types it Take & { type: "take" }. On the union type, it applies to every case that has fields.
open Fable.Core
open Fickl.Core
type Take =
abstract fromIndex: int
abstract toIndex: int
[<RequireQualifiedAccess; TypeScriptTaggedUnion("type")>]
type Command =
| [<InlineInterface>] Take of Take
| Save
let take (range: Take) = Command.Take range
let length (command: Command) =
match command with
| Command.Take t -> t.toIndex - t.fromIndex
| Command.Save -> 0
import { getInlineInterfaceValue, withInlineInterfaceTag } from "@fable-org/fable-library-js/TaggedUnion.js";
export function take(range) {
return withInlineInterfaceTag(range, "type", "take");
}
export function length(command) {
if (command.type === "save") {
return 0;
}
else {
const t = getInlineInterfaceValue(command);
return (t.toIndex - t.fromIndex) | 0;
}
}
import type { int32 } from "@fable-org/fable-library-ts/Int32.ts";
import { getInlineInterfaceValue, withInlineInterfaceTag } from "@fable-org/fable-library-ts/TaggedUnion.ts";
export interface Take {
get fromIndex(): int32,
get toIndex(): int32
}
export type Command =
| Take & { type: "take" }
| { type: "save" }
export function take(range: Take): Command {
return withInlineInterfaceTag(range, "type", "take") as Command;
}
export function length(command: Command): int32 {
if (command.type === "save") {
return 0;
}
else {
const t: Take = getInlineInterfaceValue(command as Take);
return (t.toIndex - t.fromIndex) | 0;
}
}
When the field value already carries the case's tag, the case is the value itself. Otherwise the tag is added without changing the value, and the InlineInterfaceRepresentation argument chooses how:
View, the default, is a tagged view that forwards to the value, so JavaScript sees its current state. It cannot be structured-cloned or frozen.Copycopies the value's own enumerable properties, with its prototype and the tag, into an ordinary object. Later writes do not reach the original.CopyPlainObjectscopies a plain object and makes a view of anything else.
An argument on a case overrides one on the union type. Reading the field, Command.Take t, gives back the original object.
Telling erased cases apart: [<CaseTest>]
An [<Erase>] union is matched by the runtime type of its payload, so two cases holding a string cannot be told apart. [<CaseTest>] gives a case a JavaScript predicate in Emit syntax, where $0 is the tested value.
open Fable.Core
open Fickl.Core
[<RequireQualifiedAccess; Erase>]
type Reference =
| [<CaseTest("$0.startsWith('n:')")>] Name of string
| Slug of string
let describe (reference: Reference) =
match reference with
| Reference.Name name -> "name " + name
| Reference.Slug slug -> "slug " + slug
export function describe(reference) {
let unionValue;
if ((unionValue = reference, (typeof unionValue === "string") && (!(unionValue.startsWith('n:'))))) {
return "slug " + reference;
}
else {
return "name " + reference;
}
}
export type Reference =
| string
| string
export function describe(reference: Reference): string {
let unionValue: Reference = undefined as any;
if ((unionValue = reference, (typeof unionValue === "string") && (!(unionValue.startsWith('n:'))))) {
return "slug " + reference;
}
else {
return "name " + reference;
}
}
The predicate replaces the payload type test of its case. Construction and extraction still erase to the payload: no tag is added and no copy is made, so the predicate must recognise the values of its case. Cases without the attribute keep their ordinary type tests.
On a [<TypeScriptTaggedUnion>], [<CaseTest>] is an extra test checked after the tag matches, for cases that share a tag through the same CompiledName. Fable.Core.CaseTest keeps the payload type test when one exists; Fickl.Core.CaseTest replaces it.
Labelled and optional tuple elements
A single-case [<Erase>] union with several fields compiles to an array. When the fields are named, TypeScript annotates it as a labelled tuple, [key: string, value: int32], and trailing option fields become optional elements.
open Fable.Core
type Init =
abstract method: string
[<Erase>]
type FetchArgs = FetchArgs of url: string * init: Init option
let index = FetchArgs("/", None)
let send (init: Init) = FetchArgs("/submit", Some init)
let url (FetchArgs(url, _)) = url
export const index = ["/"];
export function send(init) {
return ["/submit", init];
}
export function url(_arg) {
return _arg[0];
}
import type { Option } from "@fable-org/fable-library-ts/Option.ts";
export interface Init {
get method(): string
}
export type FetchArgs = [url: string, init?: Option<Init>];
export const index = ["/"] as FetchArgs;
export function send(init: Init): [string, Option<Init>] {
return ["/submit", init] as [string, Option<Init>];
}
export function url(_arg: FetchArgs): string {
return _arg[0];
}
The type is [url: string, init?: Option<Init>], and a trailing None is left out of the array, so FetchArgs("/", None) is ["/"] and TypeScript can pass the shorter tuple to a function such as fetch.
Type guards: [<TypePredicate>], [<TypeAssertion>]
[<TypePredicate("x", typeof<User>)>] emits a function as a TypeScript type predicate, x is User, and [<TypeAssertion("x", typeof<User>)>] as an assertion function, asserts x is User. The first argument names the narrowed parameter.
F# can see the guard as returning the narrowed value: a predicate returns bool, User option or User voption, and an assertion returns unit or Result<User, exn>. The compiled function still returns a boolean, or returns nothing and throws the exception of Error, and F# calls get Some x or Ok x back.
open Fickl.Core
type User = { Name: string }
[<TypePredicate("value", typeof<User>)>]
let tryUser (value: obj) : User option =
match value with
| :? User as user -> Some user
| _ -> None
[<TypeAssertion("value", typeof<User>)>]
let checkUser (value: obj) : Result<User, exn> =
match value with
| :? User as user -> Ok user
| _ -> Error(exn "not a user")
let greet (value: obj) =
match tryUser value with
| Some user -> "Hello " + user.Name
| None -> "Hello"
import { Record } from "@fable-org/fable-library-js/Types.js";
import { record_type, string_type } from "@fable-org/fable-library-js/Reflection.js";
import { FSharpResult$2 } from "@fable-org/fable-library-js/Result.js";
import { Exception } from "@fable-org/fable-library-js/Util.js";
export class User extends Record {
constructor(Name) {
super();
this.Name = Name;
}
}
export function User_$reflection() {
return record_type("Example7.User", [], User, () => [["Name", string_type]]);
}
export function tryUser(value) {
let user;
return ((value instanceof User) ? ((user = value, user)) : undefined) != null;
}
export function checkUser(value) {
let user;
const result = (value instanceof User) ? ((user = value, new FSharpResult$2(/* Ok */ 0, [user]))) : (new FSharpResult$2(/* Error */ 1, [new Exception("not a user")]));
if (result.tag === 0) {
}
else {
throw result.fields[0];
}
}
export function greet(value) {
const matchValue = tryUser(value) ? value : undefined;
if (matchValue == null) {
return "Hello";
}
else {
return "Hello " + matchValue.Name;
}
}
import { Record } from "@fable-org/fable-library-ts/Types.ts";
import { Exception, type IComparable, type IEquatable } from "@fable-org/fable-library-ts/Util.ts";
import { record_type, string_type, type TypeInfo } from "@fable-org/fable-library-ts/Reflection.ts";
import { type FSharpResult$2_$union, FSharpResult$2_Error$, FSharpResult$2_Ok } from "@fable-org/fable-library-ts/Result.ts";
import type { int32 } from "@fable-org/fable-library-ts/Int32.ts";
import type { Option } from "@fable-org/fable-library-ts/Option.ts";
export class User extends Record implements IEquatable<User>, IComparable<User> {
readonly Name: string;
constructor(Name: string) {
super();
this.Name = Name;
}
}
export function User_$reflection(): TypeInfo {
return record_type("Example7.User", [], User, () => [["Name", string_type]]);
}
export function tryUser(value: any): value is User {
let user: User = undefined as any;
return ((value instanceof User) ? ((user = (value as User), user)) : undefined) != null;
}
export function checkUser(value: any): asserts value is User {
let user: User = undefined as any;
const result: FSharpResult$2_$union<User, Exception> = (value instanceof User) ? ((user = (value as User), FSharpResult$2_Ok<User, Exception>(user))) : FSharpResult$2_Error$<User, Exception>(new Exception("not a user"));
if ((result.tag as int32) === /* Ok */ 0) {
}
else {
throw result.fields[0] as Exception;
}
}
export function greet(value: any): string {
const matchValue: Option<User> = tryUser(value) ? (value as any as User) : undefined;
if (matchValue == null) {
return "Hello";
}
else {
return "Hello " + (matchValue as User).Name;
}
}
The attributes apply to module functions and non-abstract class methods. They also bind an imported guard: put [<TypePredicate>] next to [<Import>] on a jsNative function, such as axios's isAxiosError.