By implementing the encode and decode methods manually, you have total control over how data should be encoded and decoded. This is also used in the most basic building blocks you’ll normally use, such as in StringDecoder and AutoEncoder - both implement the encode/decode methods (StringDecoder does not need to implement the encode method because it is already a plain data structure).

class StringDecoder implements Decoder<string> {
    decode(data: Data): string {
        if (typeof data.value === 'string') {
            return data.value;
        }
        throw new SimpleError({
            code: 'invalid_field',
            message: `Expected a string at ${data.currentField}`,
            field: data.currentField,
        });
    }

    isDefaultValue(value: unknown): boolean {
        return value === '';
    }

    getDefaultValue(): string {
        return '';
    }
}

// We export an instance to prevent creating a new instance every time we need to decode a number
export default new StringDecoder();

This is useful in some very specific cases.

Examples

Tokens

export class Token implements Encodeable {
    accessToken: string;
    refreshToken: string;
    accessTokenValidUntil: Date;

    constructor(token: { accessToken: string; refreshToken: string; accessTokenValidUntil: Date }) {
        this.accessToken = token.accessToken;
        this.refreshToken = token.refreshToken;
        this.accessTokenValidUntil = token.accessTokenValidUntil;
    }

    static decode(data: Data): Token {
        const expiresOn = data.optionalField('expires_on')?.integer;
        return new Token({
            accessToken: data.field('access_token').string,
            refreshToken: data.field('refresh_token').string,
            accessTokenValidUntil: new Date(expiresOn ? expiresOn : new Date().getTime() + data.field('expires_in').integer * 1000),
        });
    }

    encode(_context: EncodeContext): any {
        // We convert to snake case, as specified in the OAuth2 specs
        return {
            token_type: 'bearer',
            access_token: this.accessToken,
            refresh_token: this.refreshToken,
            expires_in: Math.floor((this.accessTokenValidUntil.getTime() - new Date().getTime()) / 1000),
            expires_on: Math.floor(this.accessTokenValidUntil.getTime() / 1000),
        };
    }

    needsRefresh(): boolean {
        return this.accessToken.length === 0 || this.accessTokenValidUntil < new Date();
    }
}

TranslatedString

We also use it for TranslatedString (shared/structures/src/TranslatedString.ts), which can be encoded to a normal string when not translated, or a map when it is translated. This also helps maintain backwards compability when we make an existing string property translateable.

File

For example, for File we manually implement it to prevent that you can link private files that are not trusted (not signed).

// Note: File also implements Decoder<File>, but only as a static method, and TypeScript does not support static 'implements'
export class File implements Encodeable {
    id: string;
    server: string;
    name: string | null;
    path: string;
    size: number;
    isPrivate: boolean = false;
		signedUrl: string | null = null;

    /**
     * A signature that proves that this file was generated by the server and is trusted. Without a valid signature,
     * the backend won't return a signedUrl for accessing the file.
     */
    signature: string | null = null;

    constructor(data: { id: string; server: string; path: string; size: number; name?: string | null; isPrivate?: boolean; signedUrl?: string | null; signature?: string | null }) {
        this.id = data.id;
        this.server = data.server;
        this.path = data.path;
        this.size = data.size;
        this.name = data.name ?? null;
        this.isPrivate = data.isPrivate ?? false;
        this.signedUrl = data.signedUrl ?? null;
        this.signature = data.signature ?? null;
    }

    static decode(data: Data): File {
        const file = new File({
            id: data.field('id').string,
            server: data.field('server').string,
            path: data.field('path').string,
            size: data.field('size').integer,
            name: data.optionalField('name')?.string ?? null,

            isPrivate: data.optionalField('isPrivate')?.boolean ?? false,
            signedUrl: data.optionalField('signedUrl')?.string ?? null,
            signature: data.optionalField('signature')?.string ?? null,
        });

        if (data.context.medium === EncodeMedium.Database || !file.isPrivate || !file.signature) {
            // Clear signed url that we read from the database - these won't be valid any longer
            file.signedUrl = null;
        }

        if (file.isPrivate && this.signingEnabled && (!data.context.medium || data.context.medium === EncodeMedium.Network)) {
            // A signature is required
            // Because of the sync nature of decoding, we cannot verify it here, but we need to do so when using the file
            if (!file.signature) {
                throw new SimpleError({
                    code: 'missing_signature',
                    message: 'Missing signature for private file',
                });
            }
        }

        return file;
    }

    encode(context: EncodeContext) {
        return {
            id: this.id,
            server: this.server,
            path: this.path,
            size: this.size,
            name: this.name,
            isPrivate: this.isPrivate,
            signedUrl: this.isPrivate && this.signedUrl ? this.signedUrl : undefined,
            signature: this.isPrivate ? this.signature : undefined,
        };
    }
}