From b68354b0524c5da9023b1f4c3bfb087e5d634055 Mon Sep 17 00:00:00 2001 From: Robert Sesek Date: Sat, 22 Feb 2020 14:34:12 -0500 Subject: [PATCH] Strongly type Forms on TaxReturn. This lets type information be propagated for checking using ReferenceLine and AccumulatorLine. Two side effects of this change: - The lines type is now exposed on Form, so that Line subclasses can use the type information. - ReferenceLines to the same form have to erase the FormClass type. --- src/Form.test.ts | 18 ++++++++++- src/Form.ts | 12 +++++++ src/Line.test.ts | 55 +++++++++++++++++++++++++------- src/Line.ts | 36 +++++++++++++-------- src/TaxReturn.test.ts | 69 ++++++++++++++++++++++++++++++++++++++--- src/TaxReturn.ts | 31 +++++++++--------- src/fed2019/Form1040.ts | 64 +++++++++++++++++++++++--------------- src/fed2019/Form8959.ts | 9 +++--- 8 files changed, 219 insertions(+), 75 deletions(-) diff --git a/src/Form.test.ts b/src/Form.test.ts index 122ca5d..c61ad58 100644 --- a/src/Form.test.ts +++ b/src/Form.test.ts @@ -1,6 +1,6 @@ import { ComputedLine, Line } from './Line'; import TaxReturn from './TaxReturn'; -import Form from './Form'; +import Form, { isFormT } from './Form'; import { InconsistencyError, NotFoundError } from './Errors'; test('add and get line', () => { @@ -67,6 +67,22 @@ test('get value', () => { //expect(() => f.getValue(tr, 'other')).toThrow(NotFoundError); }); +test('form types', () => { + class FormA extends Form { + readonly name = 'A'; + protected readonly _lines = {}; + }; + class FormB extends Form { + readonly name = 'B'; + protected readonly _lines = {}; + }; + + expect(isFormT(new FormA(), FormA)).toBe(true); + expect(isFormT(new FormB(), FormA)).toBe(false); + expect(isFormT(new FormA(), FormB)).toBe(false); + expect(isFormT(new FormB(), FormB)).toBe(true); +}); + /* abstract class Form2 } , I> { abstract readonly name: string; diff --git a/src/Form.ts b/src/Form.ts index f438337..7f2356d 100644 --- a/src/Form.ts +++ b/src/Form.ts @@ -12,6 +12,10 @@ export default abstract class Form }, private readonly _input?: I; + // Avoid using this; prefer the getLine() helpers declared below. This + // is only exposed for propagating line type information. + get lines(): L { return this._lines; } + constructor(input?: I) { this._input = input; } @@ -42,3 +46,11 @@ export default abstract class Form }, return this._input[name]; } }; + +export type FormClass> = new (...args: any[]) => T; + +export function isFormT>(form: Form, + formClass: FormClass): + form is T { + return form.constructor === formClass; +} diff --git a/src/Line.test.ts b/src/Line.test.ts index 7a2c531..0014ce6 100644 --- a/src/Line.test.ts +++ b/src/Line.test.ts @@ -1,5 +1,5 @@ import { Line, AccumulatorLine, InputLine, ReferenceLine, ComputedLine } from './Line'; -import Form from './Form'; +import Form, { FormClass } from './Form'; import TaxReturn from './TaxReturn'; import { NotFoundError } from './Errors'; @@ -33,21 +33,54 @@ test('reference line', () => { class TestForm extends Form { readonly name = 'Form 1'; protected readonly _lines = { - '6b': new ConstantLine(12.34) + '6b': new ConstantLine(12.34), + 's': new ConstantLine('abc'), }; }; const tr = new TaxReturn(2019); tr.addForm(new TestForm()); - const l1 = new ReferenceLine('Form 1', '6b'); - expect(l1.value(tr)).toBe(12.34); + const l1 = new ReferenceLine(TestForm, '6b'); + let n: number = l1.value(tr); + expect(n).toBe(12.34); - const l2 = new ReferenceLine('Form 2', '6b'); - expect(() => l2.value(tr)).toThrow(NotFoundError); + const l2 = new ReferenceLine(TestForm, 's'); + let s: string = l2.value(tr); + expect(s).toBe('abc'); + + //TYPEERROR: + //const l3 = new ReferenceLine(TestForm, '7a'); + //let n2: string = l1.value(tr); + //let s2: number = l2.value(tr); +}); + +test('self reference line', () => { + class OtherForm extends Form { + readonly name = 'Form A'; + protected readonly _lines = { + '6c': new ConstantLine(55) + }; + }; + class TestForm extends Form { + readonly name = 'Form 1'; + protected readonly _lines = { + 'a': new ConstantLine(100.2), + 'b': new ReferenceLine(OtherForm, '6c'), + 'c': new ReferenceLine((TestForm as unknown) as FormClass>, 'b'), + 'd': new ReferenceLine(TestForm as any, 'b'), + }; + }; + + const tr = new TaxReturn(2019); + const f = new TestForm(); + tr.addForm(f); + tr.addForm(new OtherForm()); - const l3 = new ReferenceLine('Form 1', '7a'); - expect(() => l3.value(tr)).toThrow(NotFoundError); + expect(f.getValue(tr, 'a')).toBe(100.2); + expect(f.getValue(tr, 'b')).toBe(55); + expect(f.getValue(tr, 'c')).toBe(55); + expect(f.getValue(tr, 'd')).toBe(55); }); test('input line', () => { @@ -85,7 +118,7 @@ test('line stack', () => { readonly name = 'Z-2'; protected readonly _lines = { '2c': new ComputedLine((tr: TaxReturn, l: Line): any => { - return tr.getForm('Z').getLine('3').value(tr) * 0.2; + return tr.getForm(FormZ).getLine('3').value(tr) * 0.2; }) }; }; @@ -94,7 +127,7 @@ test('line stack', () => { tr.addForm(new FormZ({ 'input': 100 })); tr.addForm(new FormZ2()); - const l = new ReferenceLine('Z-2', '2c'); + const l = new ReferenceLine(FormZ2, '2c'); expect(l.value(tr)).toBe(20); }); @@ -112,6 +145,6 @@ test('accumulator line', () => { tr.addForm(new TestForm()); tr.addForm(new TestForm()); - const l = new AccumulatorLine('Form B', 'g'); + const l = new AccumulatorLine(TestForm, 'g'); expect(l.value(tr)).toBe(300.75); }); diff --git a/src/Line.ts b/src/Line.ts index 48141ae..e0e974a 100644 --- a/src/Line.ts +++ b/src/Line.ts @@ -1,5 +1,5 @@ import TaxReturn from './TaxReturn'; -import Form from './Form'; +import Form, { FormClass } from './Form'; export abstract class Line { private _description?: string; @@ -37,12 +37,18 @@ export class ComputedLine extends Line { } }; -export class ReferenceLine extends Line { - private _form: string; - private _line: string; +export class ReferenceLine, + L extends keyof F['lines'], + T extends ReturnType> + extends Line { + private _form: FormClass; + private _line: L; private _fallback?: T; - constructor(form: string, line: string, description?: string, fallback?: T) { + // If creating a ReferenceLine and F is the same class as the + // the one the Line is in, erase |form|'s type with |as any| to + // keep TypeScript happy. + constructor(form: FormClass, line: L, description?: string, fallback?: T) { super(description || `Reference F${form}.L${line}`); this._form = form; this._line = line; @@ -50,9 +56,11 @@ export class ReferenceLine extends Line { } value(tr: TaxReturn): T { - if (this._fallback !== undefined && !tr.maybeGetForm(this._form)) + const form: F = tr.findForm(this._form); + if (this._fallback !== undefined && !form) return this._fallback; - return tr.getForm(this._form).getLine(this._line).value(tr); + const value: T = form.getValue(tr, this._line); + return value; } }; @@ -71,19 +79,21 @@ export class InputLine extends Line } }; -export class AccumulatorLine extends Line { - private _form: string; - private _line: string; +export class AccumulatorLine, + L extends keyof F['lines']> + extends Line { + private _form: FormClass; + private _line: L; - constructor(form: string, line: string, description?: string) { + constructor(form: FormClass, line: L, description?: string) { super(description || `Accumulator F${form}.L${line}`); this._form = form; this._line = line; } value(tr: TaxReturn): number { - const forms = tr.getForms(this._form); - const reducer = (acc: number, curr: Form) => acc + (curr.getValue(tr, this._line) as number); + const forms: F[] = tr.findForms(this._form); + const reducer = (acc: number, curr: F) => acc + curr.getValue(tr, this._line); return forms.reduce(reducer, 0); } }; diff --git a/src/TaxReturn.test.ts b/src/TaxReturn.test.ts index 5914dcf..6adc7c6 100644 --- a/src/TaxReturn.test.ts +++ b/src/TaxReturn.test.ts @@ -62,7 +62,8 @@ test('single-copy forms', () => { const f = new TestForm(); tr.addForm(f); expect(() => tr.addForm(new TestForm)).toThrow(InconsistencyError); - expect(tr.getForm(f.name)).toBe(f); + expect(tr.getForm(TestForm)).toBe(f); + expect(tr.findForm(TestForm)).toBe(f); }); test('multiple-copy forms', () => { @@ -79,9 +80,10 @@ test('multiple-copy forms', () => { tr.addForm(f1); tr.addForm(f2); - expect(() => tr.getForm(f1.name)).toThrow(InconsistencyError); + expect(() => tr.getForm(TestForm)).toThrow(InconsistencyError); + expect(() => tr.findForm(TestForm)).toThrow(InconsistencyError); - const forms = tr.getForms(f1.name); + const forms = tr.findForms(TestForm); expect(forms.length).toBe(2); expect(forms).toContain(f1); expect(forms).toContain(f2); @@ -89,7 +91,64 @@ test('multiple-copy forms', () => { }); test('get non-existent form', () => { + class TestForm extends Form { + readonly name = 'Test Form'; + protected readonly _lines = null; + } const tr = new TaxReturn(2019); - expect(() => tr.getForm('form')).toThrow(NotFoundError); - expect(tr.getForms('form')).toEqual([]); + expect(() => tr.getForm(TestForm)).toThrow(NotFoundError); + expect(tr.findForm(TestForm)).toBeNull(); + expect(tr.findForms(TestForm)).toEqual([]); +}); + +type FormClass> = Function & { prototype: T }; + +class TR { + private _forms: Form[] = []; + + add(form: Form) { + this._forms.push(form); + } + + find(name: string): Form { + const forms = this._forms.filter(f => f.name == name); + if (forms.length > 0) + return forms[0]; + return null; + } + + find2>(cls: FormClass): T[] { + let forms: T[] = []; + const isT = (form: Form): form is T => form.constructor === cls; + for (let form of this._forms) { + if (isT(form)) + forms.push(form); + } + return forms; + } +}; + +test('type test', () => { + class FormA extends Form { + readonly name = 'Form A'; + protected readonly _lines = {}; + }; + class FormB extends Form { + readonly name = 'Form B'; + readonly supportsMultipleCopies = true; + protected readonly _lines = {}; + }; + + const tr = new TR(); + + tr.add(new FormA()); + tr.add(new FormB()); + + expect(tr.find('Form A')).not.toBeNull(); + + expect(tr.find2(FormB).length).toBe(1); + + tr.add(new FormB()); + expect(tr.find2(FormB).length).toBe(2); + }); diff --git a/src/TaxReturn.ts b/src/TaxReturn.ts index c8f6ca6..b1d69ea 100644 --- a/src/TaxReturn.ts +++ b/src/TaxReturn.ts @@ -1,4 +1,4 @@ -import Form from './Form'; +import Form, { FormClass, isFormT } from './Form'; import Person, { Relation } from './Person'; import { NotFoundError, InconsistencyError, UnsupportedFeatureError } from './Errors'; @@ -39,7 +39,7 @@ export default class TaxReturn { addForm(form: Form) { if (!form.supportsMultipleCopies) { - const other = this.getForms(form.name); + const other = this.findForms(form.constructor as FormClass>); if (other.length > 0) { throw new InconsistencyError(`Cannot have more than one type of form ${form.name}`); } @@ -48,25 +48,24 @@ export default class TaxReturn { this._forms.push(form); } - maybeGetForm>(name: string): T | null { - const forms = this.getForms(name); - if (forms.length == 0) { + findForm>(cls: FormClass): T | null { + const forms = this.findForms(cls); + if (forms.length == 0) return null; - } - if (forms.length > 1) { - throw new InconsistencyError(`More than 1 form named ${name}`); - } + if (forms.length > 1) + throw new InconsistencyError(`Form ${forms[0].name} has multiple copies`); return forms[0]; } - getForm>(name: string): T { - const form = this.maybeGetForm(name); - if (!form) - throw new NotFoundError(`No form named ${name}`); - return form; + findForms>(cls: FormClass): T[] { + const forms: T[] = this._forms.filter((form: Form): form is T => isFormT(form, cls)); + return forms; } - getForms>(name: string): T[] { - return this._forms.filter(f => f.name == name) as T[]; + getForm>(cls: FormClass): T { + const form = this.findForm(cls); + if (!form) + throw new NotFoundError(`No form ${cls}`); + return form; } }; diff --git a/src/fed2019/Form1040.ts b/src/fed2019/Form1040.ts index a3ad3ae..f1106d8 100644 --- a/src/fed2019/Form1040.ts +++ b/src/fed2019/Form1040.ts @@ -1,9 +1,12 @@ -import Form from '../Form'; +import Form, { FormClass } from '../Form'; import TaxReturn from '../TaxReturn'; import { Line, AccumulatorLine, ComputedLine, ReferenceLine } from '../Line'; import { UnsupportedFeatureError } from '../Errors'; import Form8959 from './Form8959'; +import Form1099INT from './Form1099INT'; +import Form1099DIV from './Form1099DIV'; +import FormW2 from './FormW2'; export enum FilingStatus { Single, @@ -21,26 +24,33 @@ export default class Form1040 extends Form { readonly name = '1040'; protected readonly _lines = { - '1': new AccumulatorLine('W-2', '1', 'Wages, salaries, tips, etc.'), - '2a': new AccumulatorLine('1099-INT', '8', 'Tax-exempt interest'), - '2b': new AccumulatorLine('1099-INT', '1', 'Taxable interest'), - '3a': new AccumulatorLine('1099-DIV', '1b', 'Qualified dividends'), - '3b': new AccumulatorLine('1099-DIV', '1a', 'Ordinary dividends'), + '1': new AccumulatorLine(FormW2, '1', 'Wages, salaries, tips, etc.'), + '2a': new AccumulatorLine(Form1099INT, '8', 'Tax-exempt interest'), + '2b': new AccumulatorLine(Form1099INT, '1', 'Taxable interest'), + '3a': new AccumulatorLine(Form1099DIV, '1b', 'Qualified dividends'), + '3b': new AccumulatorLine(Form1099DIV, '1a', 'Ordinary dividends'), // 4a and 4b are complex '4b': new ComputedLine(() => 0), '4d': new ComputedLine(() => 0), // 4c and 4d are not supported // 5a and 5b are not supported - '6': new ReferenceLine('Schedule D', '21', 'Capital gain/loss', 0), - '7a': new ReferenceLine('Schedule 1', '9', 'Other income from Schedule 1', 0), + '6': new ReferenceLine(/*'Schedule D'*/ undefined, '21', 'Capital gain/loss', 0), + '7a': new ReferenceLine(/*'Schedule 1'*/ undefined, '9', 'Other income from Schedule 1', 0), '7b': new ComputedLine((tr: TaxReturn): number => { - const lineIds = ['1', '2b', '3b', '4b', '4d', /*'5b',*/ '6', '7a']; - const lines: number[] = lineIds.map(l => this.getValue(tr, l as keyof Form1040['_lines'])); - return reduceBySum(lines); + let income = 0; + income += this.getValue(tr, '1'); + income += this.getValue(tr, '2b'); + income += this.getValue(tr, '3b'); + income += this.getValue(tr, '4b'); + income += this.getValue(tr, '4d'); + //income += this.getValue(tr, '5b'); + income += this.getValue(tr, '6'); + income += this.getValue(tr, '7a'); + return income; }, 'Total income'), - '8a': new ReferenceLine('Schedule 1', '22', 'Adjustments to income', 0), + '8a': new ReferenceLine(undefined /*'Schedule 1'*/, '22', 'Adjustments to income', 0), '8b': new ComputedLine((tr: TaxReturn): number => { return this.getValue(tr, '7b') - this.getValue(tr, '8a'); @@ -113,7 +123,7 @@ export default class Form1040 extends Form { }, 'Tax'), '12b': new ComputedLine((tr: TaxReturn): number => { - return this.getValue(tr, '12a') + tr.getForm('Schedule 2').getValue(tr, '3'); + return this.getValue(tr, '12a') + tr.getForm(Schedule2).getValue(tr, '3'); }, 'Additional tax'), // Not supported: 13a - child tax credit @@ -130,7 +140,7 @@ export default class Form1040 extends Form { return value < 0 ? 0 : value; }), - '15': new ReferenceLine('Schedule 2', '10', undefined, 0), + '15': new ReferenceLine(undefined /*'Schedule 2'*/, '10', undefined, 0), '16': new ComputedLine((tr: TaxReturn): number => { return this.getValue(tr, '14') + this.getValue(tr, '15'); @@ -138,12 +148,15 @@ export default class Form1040 extends Form { '17': new ComputedLine((tr: TaxReturn): number => { const fedTaxWithheldBoxes = [ - ['W-2', '2'], ['1099-R', '4'], ['1099-DIV', '4'], ['1099-INT', '4'] + new AccumulatorLine(FormW2, '2'), + //new AccumulatorLine(Form1099R, '4'), + new AccumulatorLine(Form1099DIV, '4'), + new AccumulatorLine(Form1099INT, '4'), ]; - const withholding = fedTaxWithheldBoxes.map(b => (new AccumulatorLine(b[0], b[1])).value(tr)); + const withholding: number[] = fedTaxWithheldBoxes.map(b => b.value(tr)); let additionalMedicare = 0; - const f8959 = tr.maybeGetForm('8595') + const f8959 = tr.findForm(Form8959) if (f8959) { additionalMedicare = f8959.getValue(tr, '24'); } @@ -153,11 +166,11 @@ export default class Form1040 extends Form { // 18 not supported - '19': new ReferenceLine('1040', '17', 'Total payments'), + '19': new ReferenceLine(Form1040 as any, '17', 'Total payments'), '20': new ComputedLine((tr: TaxReturn): number => { - const l16 = this.getValue(tr, '16'); - const l19 = this.getValue(tr, '19'); + const l16: number = this.getValue(tr, '16'); + const l19: number = this.getValue(tr, '19'); if (l19 > l16) return l19 - l16; return 0; @@ -180,8 +193,9 @@ export class Schedule2 extends Form { '1': new ComputedLine((tr: TaxReturn): number => { // TODO - this is just using Taxable Income, rather than AMT-limited // income - const taxableIncome = tr.getForm('1040').getValue(tr, '11b'); - switch (tr.getForm('1040').getInput('filingStatus')) { + const f1040 = tr.getForm(Form1040); + const taxableIncome = f1040.getValue(tr, '11b'); + switch (f1040.getInput('filingStatus')) { case FilingStatus.Single: if (taxableIncome < 510300) return 0; @@ -205,7 +219,7 @@ export class Schedule2 extends Form { // 6 is not supported (Additional tax on IRAs, other qualified retirement plans, and other tax-favored accounts) // 7 is not supported (Household employment taxes.) '8': new ComputedLine((tr: TaxReturn): number => { - const f1040 = tr.getForm('1040'); + const f1040 = tr.getForm(Form1040); const wages = f1040.getLine('1').value(tr); const agi = f1040.getLine('8b').value(tr); @@ -235,12 +249,12 @@ export class Schedule2 extends Form { let value = 0; if (additionalMedicare) { - const f8959 = tr.getForm('8959'); + const f8959 = tr.getForm(Form8959); value += f8959.getValue(tr, '18'); } if (niit) { - const f8960 = tr.getForm('8960'); + //const f8960 = tr.getForm('8960'); } return value; diff --git a/src/fed2019/Form8959.ts b/src/fed2019/Form8959.ts index 2e01a0e..93f7b43 100644 --- a/src/fed2019/Form8959.ts +++ b/src/fed2019/Form8959.ts @@ -3,12 +3,13 @@ import TaxReturn from '../TaxReturn'; import { Line, AccumulatorLine, ComputedLine, ReferenceLine } from '../Line'; import Form1040, { FilingStatus } from './Form1040'; +import FormW2 from './FormW2'; export default class Form8959 extends Form { readonly name = '8959'; protected readonly _lines = { - '1': new AccumulatorLine('W-2', '5', 'Medicare wages'), + '1': new AccumulatorLine(FormW2, '5', 'Medicare wages'), // 2 is not supported (Unreported tips from Form 4137) // 3 is not supported (Wages from Form 8919) '4': new ComputedLine((tr: TaxReturn): number => { @@ -16,7 +17,7 @@ export default class Form8959 extends Form { return this.getValue(tr, '1'); }), '5': new ComputedLine((tr: TaxReturn): number => { - return Form8959.filingStatusLimit(tr.getForm('1040').getInput('filingStatus')); + return Form8959.filingStatusLimit(tr.getForm(Form1040).getInput('filingStatus')); }), '6': new ComputedLine((tr: TaxReturn): number => { const value = this.getValue(tr, '5') - this.getValue(tr, '4'); @@ -33,8 +34,8 @@ export default class Form8959 extends Form { return this.getValue(tr, '7'); }), - '19': new AccumulatorLine('W-2', '6', 'Medicare tax withheld'), - '20': new ReferenceLine('8595', '1'), + '19': new AccumulatorLine(FormW2, '6', 'Medicare tax withheld'), + '20': new ReferenceLine(Form8959 as any, '1'), '21': new ComputedLine((tr: TaxReturn): number => { return this.getValue(tr, '20') * 0.0145; }, 'Regular Medicare withholding on Medicare wages'), -- 2.43.5