Pragmatic typed immutable.js records using typescript 2.1+

December 9, 2016 ยท View on GitHub

// Pragmatic typed immutable.js records using typescript 2.1+ // Comment with any suggestions/improvements!

import * as fs from 'fs' import { Record, Map } from 'immutable'

type Stats = fs.Stats;

// Define the basic shape. All properties should be readonly. This model // defines a folder because it seemed easy C:

interface State { readonly path: string; readonly error: string | null; readonly contents: Map<string, Stats>; }

type PartialState = Partial;

// Manually define the immutable mutators and accessors you need. All dot // property accessors are automatically provided by extending from State. // With the introduction of index types in 2.1, the basic set and update // operators are easy to set up. // // For deep operations, some delicate boilerplate is still necessary. // Delicate because the definitions are not type-safe, but their usage is. // That means you need to be very careful here, but the benefit is that // using them is totally safe if you did everything right. // // Usually your state isn't going to be used in every way that immutable // allows, so you can constrain what you implement to whatever subset you // need. In practice it honestly isn't too bad, especially if you're decent // with your editor and multi-line selection.

type Updater = (value: T) => T;

export interface IState extends State {

// All basic operations can now be defined with index types!!! set(key: K, value: State[K]): IState; update(key: K, updater: Updater<State[K]>): IState;

// Deep operations still need to be manually defined. getIn(keyPath: ['contents', string]): Stats | undefined; setIn(keyPath: ['contents', string], value: Stats): IState; deleteIn(keyPath: ['contents', string]): IState;

withMutations(mutator: (s: IState) => any): IState;

// Merge is made easy using typescript's new mapped types!!! merge(partial: PartialState): IState; mergeDeep(partial: PartialState): IState;

}

// Create the record class, using State to typecheck the default values. // Remember that optional properties need to be explicitly specified as // undefined or else the record won't acknowledge them down the line. For // this reason I prefer using unions with null instead of optional // properties.

const defaultState: State = { path: '', error: null, contents: Map<string, Stats>(), } const RecordClass = Record(defaultState, 'StateRecord');

// If this is top-level state, you may only want to expose initial state.

export const initialState = new RecordClass() as any as IState;

// If you're going to be creating multiple instances, you should export a // constructor. This can be done by forcibly asserting the RecordClass to // another function with our custom state type as the output.

type Constructor = { (input: TInput): IState; new (input: TInput): IState; }

export const StateRecord = RecordClass as any as Constructor;

// If you don't want to make use of defaults, you can use the State // interface directly as the input type.

export const StateRecordWithNoDefaults = RecordClass as any as Constructor;

// You can even specify a subset of keys that you want to be required and // optional, though this is getting pretty spicy.

type Input<T, Required extends keyof T, Optional extends keyof T> = { [R in Required]: T[R]; } & { [O in Optional]?: T[O]; }

type StateInput = Input<State, 'path', 'contents' | 'error'>;

export const StateRecordWithSpicyInput = RecordClass as any as Constructor;

// Also when you're going to be making multiple records it's usually // convenient to export a type with the same name as your constructor // function; you're importing less and defining collections of your record // looks nicer. // // - import { FileRecord, IFile } from './state' // + import { FileRecord } from './state' // - let s = new Set() // + let s = new Set() // s.add(new FileRecord(/* whatever */))

export type StateRecord = IState;